mirror of
https://github.com/langgenius/dify.git
synced 2026-09-09 05:41:00 +08:00
fix: make panel and node resizing keyboard accessible (#41963)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
parent
9e0763e189
commit
a233b2f53a
@ -221,11 +221,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx": {
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 3
|
||||
@ -2947,7 +2942,7 @@
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 6
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/components/snippets/hooks/use-snippet-run.ts": {
|
||||
@ -4438,7 +4433,7 @@
|
||||
"count": 4
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 5
|
||||
"count": 4
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import PromptEditorHeightResizeWrap from '../prompt-editor-height-resize-wrap'
|
||||
|
||||
function Editor({ hideResize = false }: { hideResize?: boolean }) {
|
||||
const [height, setHeight] = useState(200)
|
||||
return (
|
||||
<PromptEditorHeightResizeWrap
|
||||
height={height}
|
||||
minHeight={120}
|
||||
onHeightChange={setHeight}
|
||||
hideResize={hideResize}
|
||||
>
|
||||
<textarea aria-label="Prompt" />
|
||||
</PromptEditorHeightResizeWrap>
|
||||
)
|
||||
}
|
||||
|
||||
describe('PromptEditorHeightResizeWrap', () => {
|
||||
it('supports keyboard height changes without intercepting editor keys or imposing a maximum', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Editor />)
|
||||
const handle = screen.getByRole('button', { name: 'common.resize.editor' })
|
||||
const editor = document.getElementById(handle.getAttribute('aria-controls')!)!
|
||||
await user.tab()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(editor).toHaveStyle({ height: '200px' })
|
||||
await user.tab()
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{ArrowDown}{Shift>}{ArrowDown}{/Shift}')
|
||||
expect(editor).toHaveStyle({ height: '240px' })
|
||||
await user.keyboard('{ArrowUp}{End}')
|
||||
expect(editor).toHaveStyle({ height: '232px' })
|
||||
await user.keyboard('{Home}{ArrowUp}')
|
||||
expect(editor).toHaveStyle({ height: '120px' })
|
||||
await user.keyboard('{ArrowDown}{Enter}')
|
||||
expect(editor).toHaveStyle({ height: '120px' })
|
||||
await user.tab({ shift: true })
|
||||
expect(screen.getByRole('textbox', { name: 'Prompt' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'continues resizing by keyboard after a mouse drag (click on release: %s)',
|
||||
async (clickOnRelease) => {
|
||||
const user = userEvent.setup()
|
||||
render(<Editor />)
|
||||
const handle = screen.getByRole('button', { name: 'common.resize.editor' })
|
||||
const editor = document.getElementById(handle.getAttribute('aria-controls')!)!
|
||||
fireEvent.mouseDown(handle, { clientY: 200 })
|
||||
fireEvent.mouseMove(document, { clientY: 260 })
|
||||
await waitFor(() => expect(editor).toHaveStyle({ height: '260px' }))
|
||||
fireEvent.mouseUp(document)
|
||||
if (clickOnRelease) fireEvent.click(handle, { detail: 1 })
|
||||
expect(editor).toHaveStyle({ height: '260px' })
|
||||
await user.tab()
|
||||
await user.tab()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(editor).toHaveStyle({ height: '268px' })
|
||||
await user.keyboard('{Enter}')
|
||||
expect(editor).toHaveStyle({ height: '120px' })
|
||||
},
|
||||
)
|
||||
|
||||
it('does not expose a resize control when resizing is hidden', () => {
|
||||
render(<Editor hideResize />)
|
||||
expect(screen.queryByRole('button', { name: 'common.resize.editor' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getKeyboardResizeValue } from '@/app/components/base/resize-handle/keyboard'
|
||||
|
||||
type Props = Readonly<{
|
||||
className?: string
|
||||
@ -24,6 +27,10 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({
|
||||
footer,
|
||||
hideResize,
|
||||
}) => {
|
||||
const { t } = useTranslation('common')
|
||||
const editorId = useId()
|
||||
const resizeDescriptionId = useId()
|
||||
const didDragRef = useRef(false)
|
||||
const [clientY, setClientY] = useState(0)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [prevUserSelectStyle, setPrevUserSelectStyle] = useState(
|
||||
@ -33,6 +40,7 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({
|
||||
|
||||
const handleStartResize = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
didDragRef.current = false
|
||||
setClientY(e.clientY)
|
||||
setIsResizing(true)
|
||||
setOldHeight(height)
|
||||
@ -61,7 +69,13 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({
|
||||
},
|
||||
)
|
||||
|
||||
const handleResize = useCallback(didHandleResize, [isResizing, height, minHeight, clientY])
|
||||
const handleResize = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (isResizing && event.clientY !== clientY) didDragRef.current = true
|
||||
didHandleResize(event)
|
||||
},
|
||||
[didHandleResize, isResizing, clientY],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousemove', handleResize)
|
||||
@ -80,6 +94,7 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
id={editorId}
|
||||
className={cn(className, 'overflow-y-auto')}
|
||||
style={{
|
||||
height,
|
||||
@ -90,12 +105,40 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({
|
||||
{/* resize handler */}
|
||||
{footer}
|
||||
{!hideResize && (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 flex h-2 w-full cursor-row-resize justify-center"
|
||||
onMouseDown={handleStartResize}
|
||||
>
|
||||
<div className="h-0.75 w-5 rounded-xs bg-gray-300"></div>
|
||||
</div>
|
||||
<>
|
||||
<IconButton
|
||||
aria-label={t(($) => $['resize.editor'])}
|
||||
aria-controls={editorId}
|
||||
aria-describedby={resizeDescriptionId}
|
||||
className="group/resize absolute bottom-0 left-0 h-2 w-full cursor-row-resize"
|
||||
onMouseDown={handleStartResize}
|
||||
onClick={(event) => {
|
||||
if (event.detail === 0 || !didDragRef.current) onHeightChange(minHeight)
|
||||
didDragRef.current = false
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
|
||||
const next = getKeyboardResizeValue(event, {
|
||||
side: 'bottom',
|
||||
value: height,
|
||||
min: minHeight,
|
||||
})
|
||||
if (next === undefined) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onHeightChange(next)
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-0.75 w-5 rounded-xs bg-state-base-handle group-focus-visible/resize:w-full group-focus-visible/resize:bg-state-accent-solid"
|
||||
/>
|
||||
</IconButton>
|
||||
<span id={resizeDescriptionId} className="sr-only" aria-live="polite">
|
||||
{t(($) => $['resize.height'], { height: Math.round(height) })}{' '}
|
||||
{t(($) => $['resize.editorHelp'])}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import ResizeHandle from '..'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
const { default: common } = await import('@/i18n/en-US/common.json')
|
||||
return createReactI18nextMock(common)
|
||||
})
|
||||
|
||||
function Panel({ side }: Pick<ComponentProps<typeof ResizeHandle>, 'side'>) {
|
||||
const [value, setValue] = useState(480)
|
||||
return (
|
||||
<>
|
||||
<ResizeHandle
|
||||
side={side}
|
||||
value={value}
|
||||
min={400}
|
||||
max={600}
|
||||
controls="panel"
|
||||
label="Panel size"
|
||||
onResize={setValue}
|
||||
/>
|
||||
<div id="panel">{value}</div>
|
||||
<button>Next control</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ResizeHandle', () => {
|
||||
it.each([
|
||||
['left', 'ArrowLeft', 'ArrowRight', 'vertical'],
|
||||
['right', 'ArrowRight', 'ArrowLeft', 'vertical'],
|
||||
['top', 'ArrowUp', 'ArrowDown', 'horizontal'],
|
||||
['bottom', 'ArrowDown', 'ArrowUp', 'horizontal'],
|
||||
] as const)(
|
||||
'resizes from the %s edge and exposes the current size',
|
||||
async (side, increase, decrease, orientation) => {
|
||||
const user = userEvent.setup()
|
||||
render(<Panel side={side} />)
|
||||
await user.tab()
|
||||
const handle = screen.getByRole('separator', { name: 'Panel size' })
|
||||
expect(handle).toHaveFocus()
|
||||
expect(handle).toHaveAttribute('aria-orientation', orientation)
|
||||
await user.keyboard(`{${increase}}{Shift>}{${increase}}{/Shift}`)
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '520')
|
||||
expect(handle).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
`${orientation === 'vertical' ? 'Width' : 'Height'}: 520 pixels`,
|
||||
)
|
||||
await user.keyboard(`{${decrease}}`)
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '512')
|
||||
await user.keyboard(`{Home}{${decrease}}`)
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '400')
|
||||
await user.keyboard(`{End}{${increase}}`)
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '600')
|
||||
await user.tab()
|
||||
expect(screen.getByRole('button', { name: 'Next control' })).toHaveFocus()
|
||||
await user.tab({ shift: true })
|
||||
expect(handle).toHaveFocus()
|
||||
},
|
||||
)
|
||||
|
||||
it('does not consume unrelated shortcuts and retains the mouse handler', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onResize = vi.fn()
|
||||
const onMouseDown = vi.fn()
|
||||
render(
|
||||
<ResizeHandle
|
||||
side="left"
|
||||
value={480}
|
||||
min={400}
|
||||
max={600}
|
||||
controls="panel"
|
||||
label="Panel size"
|
||||
onResize={onResize}
|
||||
onMouseDown={onMouseDown}
|
||||
/>,
|
||||
)
|
||||
const handle = screen.getByRole('separator', { name: 'Panel size' })
|
||||
await user.tab()
|
||||
await user.keyboard('{ArrowDown}{Control>}{ArrowRight}{/Control}{Alt>}{ArrowLeft}{/Alt}')
|
||||
expect(onResize).not.toHaveBeenCalled()
|
||||
fireEvent.mouseDown(handle)
|
||||
expect(onMouseDown).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
64
web/app/components/base/resize-handle/index.tsx
Normal file
64
web/app/components/base/resize-handle/index.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getKeyboardResizeValue } from './keyboard'
|
||||
|
||||
type ResizeHandleProps = Pick<
|
||||
ComponentProps<'div'>,
|
||||
'ref' | 'className' | 'children' | 'onMouseDown' | 'onPointerDown'
|
||||
> & {
|
||||
side: 'left' | 'right' | 'top' | 'bottom'
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
label: string
|
||||
controls: string
|
||||
onResize: (value: number) => void
|
||||
}
|
||||
|
||||
export default function ResizeHandle({
|
||||
side,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
label,
|
||||
controls,
|
||||
onResize,
|
||||
className,
|
||||
...props
|
||||
}: ResizeHandleProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const horizontal = side === 'left' || side === 'right'
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
aria-controls={controls}
|
||||
aria-orientation={horizontal ? 'vertical' : 'horizontal'}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={Math.max(min, max)}
|
||||
aria-valuenow={value}
|
||||
aria-valuetext={
|
||||
horizontal
|
||||
? t(($) => $['resize.width'], { width: Math.round(value) })
|
||||
: t(($) => $['resize.height'], { height: Math.round(value) })
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
const next = getKeyboardResizeValue(event, { side, value, min, max })
|
||||
if (next === undefined) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onResize(next)
|
||||
}}
|
||||
className={cn(
|
||||
'group/resize rounded-sm focus-visible:bg-state-accent-solid focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
30
web/app/components/base/resize-handle/keyboard.ts
Normal file
30
web/app/components/base/resize-handle/keyboard.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { KeyboardEvent } from 'react'
|
||||
|
||||
type ResizeOptions = {
|
||||
side: 'left' | 'right' | 'top' | 'bottom'
|
||||
value: number
|
||||
min: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
export function getKeyboardResizeValue(
|
||||
event: Pick<KeyboardEvent, 'key' | 'shiftKey' | 'altKey' | 'ctrlKey' | 'metaKey'>,
|
||||
{ side, value, min, max = Infinity }: ResizeOptions,
|
||||
) {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return undefined
|
||||
|
||||
const step = event.shiftKey ? 32 : 8
|
||||
const horizontal = side === 'left' || side === 'right'
|
||||
const backward = horizontal ? 'ArrowLeft' : 'ArrowUp'
|
||||
const forward = horizontal ? 'ArrowRight' : 'ArrowDown'
|
||||
const sign = side === 'left' || side === 'top' ? -1 : 1
|
||||
const upperBound = Math.max(min, max)
|
||||
let next: number
|
||||
if (event.key === backward) next = value - step * sign
|
||||
else if (event.key === forward) next = value + step * sign
|
||||
else if (event.key === 'Home') next = min
|
||||
else if (event.key === 'End' && Number.isFinite(upperBound)) next = upperBound
|
||||
else return undefined
|
||||
|
||||
return Math.max(min, Math.min(next, upperBound))
|
||||
}
|
||||
@ -1,11 +1,17 @@
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { ReactFlowProvider } from 'reactflow'
|
||||
import { renderWorkflowComponent as renderWithWorkflowStore } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import SnippetRunPanel from '../snippet-run-panel'
|
||||
|
||||
const renderWorkflowComponent = (
|
||||
ui: Parameters<typeof renderWithWorkflowStore>[0],
|
||||
options?: Parameters<typeof renderWithWorkflowStore>[1],
|
||||
) => renderWithWorkflowStore(<ReactFlowProvider>{ui}</ReactFlowProvider>, options)
|
||||
|
||||
const workflowHookMocks = vi.hoisted(() => ({
|
||||
handleCancelDebugAndPreviewPanel: vi.fn(),
|
||||
handleRun: vi.fn(),
|
||||
@ -98,6 +104,22 @@ describe('SnippetRunPanel', () => {
|
||||
checkInputMocks.checkInputsForm.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('resizes the run panel with the keyboard within the available canvas width', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkflowComponent(<SnippetRunPanel fields={[]} />, {
|
||||
initialStoreState: { previewPanelWidth: 480, workflowCanvasWidth: 1000 },
|
||||
})
|
||||
await user.tab()
|
||||
const handle = screen.getByRole('separator', { name: 'workflow.singleRun.testRun' })
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{ArrowLeft}{Shift>}{ArrowLeft}{/Shift}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '520')
|
||||
await user.keyboard('{End}{ArrowLeft}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '600')
|
||||
await user.keyboard('{Home}{ArrowRight}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '400')
|
||||
})
|
||||
|
||||
it('should render snippet input fields with defaults and run with edited inputs', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
@ -6,14 +6,17 @@ import type { SnippetInputField } from '@/models/snippet'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useReactFlowStore } from 'reactflow'
|
||||
import { useCheckInputsForms } from '@/app/components/base/chat/chat/check-input-forms-hooks'
|
||||
import { getProcessedInputs } from '@/app/components/base/chat/chat/utils'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import ResizeHandle from '@/app/components/base/resize-handle'
|
||||
import { useWorkflowInteractions } from '@/app/components/workflow/hooks/use-workflow-panel-interactions'
|
||||
import { useWorkflowRun } from '@/app/components/workflow/hooks/use-workflow-run'
|
||||
import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item'
|
||||
import { getPreviewPanelMaxWidth } from '@/app/components/workflow/panel/panel-width'
|
||||
import ResultPanel from '@/app/components/workflow/run/result-panel'
|
||||
import ResultText from '@/app/components/workflow/run/result-text'
|
||||
import TracingPanel from '@/app/components/workflow/run/tracing-panel'
|
||||
@ -66,6 +69,7 @@ const buildInitialInputs = (fields: SnippetRunField[]) => {
|
||||
|
||||
const SnippetRunPanel = ({ fields }: SnippetRunPanelProps) => {
|
||||
const { t } = useTranslation()
|
||||
const panelId = useId()
|
||||
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
|
||||
const { handleRun } = useWorkflowRun()
|
||||
const { checkInputsForm } = useCheckInputsForms()
|
||||
@ -74,6 +78,8 @@ const SnippetRunPanel = ({ fields }: SnippetRunPanelProps) => {
|
||||
const workflowCanvasWidth = useStore((s) => s.workflowCanvasWidth)
|
||||
const panelWidth = useStore((s) => s.previewPanelWidth)
|
||||
const setPreviewPanelWidth = useStore((s) => s.setPreviewPanelWidth)
|
||||
const hasSelectedNode = useReactFlowStore((s) => s.getNodes().some((node) => node.data.selected))
|
||||
const maxPanelWidth = getPreviewPanelMaxWidth(workflowCanvasWidth, hasSelectedNode)
|
||||
|
||||
const previewFields = useMemo(() => buildPreviewFields(fields), [fields])
|
||||
const initialInputs = useMemo(() => buildInitialInputs(previewFields), [previewFields])
|
||||
@ -125,12 +131,9 @@ const SnippetRunPanel = ({ fields }: SnippetRunPanelProps) => {
|
||||
if (!isResizing) return
|
||||
|
||||
const newWidth = window.innerWidth - e.clientX
|
||||
const reservedCanvasWidth = 400
|
||||
const maxAllowed = workflowCanvasWidth ? workflowCanvasWidth - reservedCanvasWidth : 1024
|
||||
|
||||
if (newWidth >= 400 && newWidth <= maxAllowed) setPreviewPanelWidth(newWidth)
|
||||
if (newWidth >= 400 && newWidth <= maxPanelWidth) setPreviewPanelWidth(newWidth)
|
||||
},
|
||||
[isResizing, setPreviewPanelWidth, workflowCanvasWidth],
|
||||
[isResizing, setPreviewPanelWidth, maxPanelWidth],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@ -144,11 +147,19 @@ const SnippetRunPanel = ({ fields }: SnippetRunPanelProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
id={panelId}
|
||||
className="relative flex h-full flex-col rounded-l-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl"
|
||||
style={{ width: `${panelWidth}px` }}
|
||||
>
|
||||
<div
|
||||
className="absolute top-1/2 bottom-0 left-0.75 z-50 h-6 w-0.75 cursor-col-resize rounded bg-gray-300"
|
||||
<ResizeHandle
|
||||
side="left"
|
||||
value={panelWidth}
|
||||
min={400}
|
||||
max={maxPanelWidth}
|
||||
controls={panelId}
|
||||
label={t(($) => $['singleRun.testRun'], { ns: 'workflow' })}
|
||||
onResize={setPreviewPanelWidth}
|
||||
className="absolute top-1/2 bottom-0 left-0.75 z-50 h-6 w-0.75 cursor-col-resize bg-state-base-handle"
|
||||
onMouseDown={startResizing}
|
||||
/>
|
||||
<div className="flex items-center justify-between p-4 pb-1 text-base font-semibold text-text-primary">
|
||||
|
||||
@ -0,0 +1,187 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { NodeProps, ResizeParamsWithDirection } from 'reactflow'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import ReactFlow from 'reactflow'
|
||||
import { page, userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import NoteNode from '@/app/components/workflow/note-node'
|
||||
import { NoteTheme } from '@/app/components/workflow/note-node/types'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import NodeResizer from '../node-resizer'
|
||||
import 'reactflow/dist/style.css'
|
||||
|
||||
vi.mock('../../../../hooks/use-workflow', () => ({
|
||||
useNodesReadOnly: () => ({ nodesReadOnly: false }),
|
||||
}))
|
||||
|
||||
// Editing and persistence are independent of the Note's rendered resize affordance.
|
||||
vi.mock('../../../../note-node/note-editor', () => ({
|
||||
NoteEditorContextProvider: ({ children }: { children: ReactNode }) => children,
|
||||
NoteEditor: () => <span>Note content</span>,
|
||||
NoteEditorToolbar: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../note-node/hooks', () => ({
|
||||
useNote: () => ({
|
||||
handleThemeChange: vi.fn(),
|
||||
handleEditorChange: vi.fn(),
|
||||
handleShowAuthorChange: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../hooks/use-node-data-update', () => ({
|
||||
useNodeDataUpdate: () => ({ handleNodeDataUpdateWithSyncDraft: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../hooks/use-nodes-interactions', async () => {
|
||||
const { useStoreApi } = await import('reactflow')
|
||||
return {
|
||||
useNodesInteractions: () => {
|
||||
const store = useStoreApi()
|
||||
return {
|
||||
handleNodeResize: (id: string, { width, height }: ResizeParamsWithDirection) => {
|
||||
const { getNodes, setNodes } = store.getState()
|
||||
setNodes(
|
||||
getNodes().map((node) =>
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
width,
|
||||
height,
|
||||
data: { ...node.data, width, height },
|
||||
}
|
||||
: node,
|
||||
),
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function ResizableNode({ id, data }: NodeProps<CommonNodeType>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Resizable node"
|
||||
className="group relative rounded-lg bg-components-panel-bg"
|
||||
style={{ width: data.width, height: data.height }}
|
||||
>
|
||||
<NodeResizer nodeId={id} nodeData={data} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const nodeTypes = { resize: ResizableNode }
|
||||
const noteNodeTypes = { note: NoteNode }
|
||||
const nodes = [
|
||||
{
|
||||
id: 'node',
|
||||
type: 'resize',
|
||||
position: { x: 100, y: 100 },
|
||||
data: { title: 'Note', desc: '', type: BlockEnum.Iteration, width: 300, height: 200 },
|
||||
},
|
||||
]
|
||||
|
||||
it('hides an unselected Note resize handle from pointer targeting until hover or keyboard focus', async () => {
|
||||
// Browser-owned: transparent descendants still participate in CSS hit testing,
|
||||
// while native Tab focus must reveal the real Note's resize affordance.
|
||||
await page.viewport(1000, 800)
|
||||
const store = createWorkflowStore({})
|
||||
const screen = await render(
|
||||
<WorkflowContext value={store}>
|
||||
<button type="button">Before canvas</button>
|
||||
<div role="group" aria-label="Canvas" style={{ width: 800, height: 600 }}>
|
||||
<ReactFlow
|
||||
defaultNodes={[
|
||||
{
|
||||
id: 'note',
|
||||
type: 'note',
|
||||
ariaLabel: 'Note',
|
||||
position: { x: 100, y: 100 },
|
||||
data: {
|
||||
title: 'Note',
|
||||
desc: '',
|
||||
type: BlockEnum.Code,
|
||||
text: '',
|
||||
theme: NoteTheme.blue,
|
||||
author: 'Alice',
|
||||
showAuthor: false,
|
||||
selected: false,
|
||||
width: 300,
|
||||
height: 200,
|
||||
},
|
||||
},
|
||||
]}
|
||||
nodeTypes={noteNodeTypes}
|
||||
/>
|
||||
</div>
|
||||
</WorkflowContext>,
|
||||
)
|
||||
const node = screen.getByRole('button', { name: 'Note', exact: true })
|
||||
const control = screen.getByRole('button', { name: 'common.resize.node' })
|
||||
const beforeCanvas = screen.getByRole('button', { name: 'Before canvas' })
|
||||
await expect.element(node).toBeVisible()
|
||||
await beforeCanvas.click()
|
||||
expect(control.element().checkVisibility({ checkOpacity: true })).toBe(false)
|
||||
const rect = control.element().getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)
|
||||
expect(control.element().contains(hit)).toBe(false)
|
||||
|
||||
await node.hover()
|
||||
expect(control.element().checkVisibility({ checkOpacity: true })).toBe(true)
|
||||
const hoveredHit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)
|
||||
expect(control.element().contains(hoveredHit)).toBe(true)
|
||||
expect(node.element().getBoundingClientRect().width).toBe(300)
|
||||
expect(node.element().getBoundingClientRect().height).toBe(200)
|
||||
await beforeCanvas.click()
|
||||
expect(control.element().checkVisibility({ checkOpacity: true })).toBe(false)
|
||||
await userEvent.tab()
|
||||
await userEvent.tab()
|
||||
await expect.element(control).toHaveFocus()
|
||||
expect(control.element().checkVisibility({ checkOpacity: true })).toBe(true)
|
||||
await userEvent.keyboard('{ArrowRight}{ArrowDown}')
|
||||
await expect.poll(() => node.element().getBoundingClientRect().width).toBe(308)
|
||||
expect(node.element().getBoundingClientRect().height).toBe(208)
|
||||
})
|
||||
|
||||
it('reveals the resize handle on keyboard focus and preserves mouse dragging', async () => {
|
||||
// Browser-owned: opacity on ancestors, native Tab navigation, focus rings and D3 drag hit testing.
|
||||
await page.viewport(1000, 800)
|
||||
const screen = await render(
|
||||
<>
|
||||
<button type="button">Before canvas</button>
|
||||
<div role="group" aria-label="Canvas" style={{ width: 800, height: 600 }}>
|
||||
<ReactFlow defaultNodes={nodes} nodeTypes={nodeTypes} />
|
||||
</div>
|
||||
</>,
|
||||
)
|
||||
const node = screen.getByRole('group', { name: 'Resizable node', exact: true })
|
||||
const control = screen.getByRole('button', { name: 'common.resize.node' })
|
||||
await expect.element(node).toBeVisible()
|
||||
await screen.getByRole('button', { name: 'Before canvas' }).click()
|
||||
await userEvent.tab()
|
||||
await userEvent.tab()
|
||||
await expect.element(control).toHaveFocus()
|
||||
expect(control.element().checkVisibility({ checkOpacity: true })).toBe(true)
|
||||
const focusStyle = getComputedStyle(control.element())
|
||||
expect(focusStyle.boxShadow !== 'none' || focusStyle.outlineStyle !== 'none').toBe(true)
|
||||
|
||||
const original = node.element().getBoundingClientRect()
|
||||
await userEvent.keyboard('{ArrowRight}{Shift>}{ArrowDown}{/Shift}')
|
||||
await expect.poll(() => node.element().getBoundingClientRect().width).toBe(308)
|
||||
expect(node.element().getBoundingClientRect().height).toBe(232)
|
||||
expect(node.element().getBoundingClientRect().left).toBe(original.left)
|
||||
expect(node.element().getBoundingClientRect().top).toBe(original.top)
|
||||
|
||||
await userEvent.dragAndDrop(control, screen.getByRole('group', { name: 'Canvas', exact: true }), {
|
||||
targetPosition: { x: 550, y: 450 },
|
||||
})
|
||||
await expect.poll(() => node.element().getBoundingClientRect().width).toBeGreaterThan(308)
|
||||
expect(node.element().getBoundingClientRect().height).toBeGreaterThan(232)
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect.poll(() => node.element().getBoundingClientRect().width).toBe(258)
|
||||
expect(node.element().getBoundingClientRect().height).toBe(152)
|
||||
})
|
||||
@ -0,0 +1,129 @@
|
||||
import type { ResizeParamsWithDirection } from 'reactflow'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import NodeResizer from '../node-resizer'
|
||||
|
||||
const resize = vi.hoisted(() => vi.fn())
|
||||
const permissions = vi.hoisted(() => ({ readonly: false }))
|
||||
|
||||
// Graph mutation owns draft persistence and history; keep React Flow and the resize UI real.
|
||||
vi.mock('../../../../hooks/use-nodes-interactions', async () => {
|
||||
const { useStoreApi } = await import('reactflow')
|
||||
return {
|
||||
useNodesInteractions: () => {
|
||||
const store = useStoreApi()
|
||||
return {
|
||||
handleNodeResize: (id: string, params: ResizeParamsWithDirection) => {
|
||||
resize(id, params)
|
||||
store.getState().setNodes(
|
||||
store
|
||||
.getState()
|
||||
.getNodes()
|
||||
.map((node) =>
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
data: { ...node.data, width: params.width, height: params.height },
|
||||
}
|
||||
: node,
|
||||
),
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../../hooks/use-workflow', () => ({
|
||||
useNodesReadOnly: () => ({ nodesReadOnly: permissions.readonly }),
|
||||
}))
|
||||
|
||||
function ResizableNode() {
|
||||
const node = useNodes<CommonNodeType>().find((node) => node.id === 'container')!
|
||||
if (!node) return null
|
||||
return (
|
||||
<>
|
||||
<NodeResizer nodeId={node.id} nodeData={node.data} />
|
||||
<output aria-label="Node size">
|
||||
{node.data.width} × {node.data.height}
|
||||
</output>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('NodeResizer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
permissions.readonly = false
|
||||
})
|
||||
|
||||
const container = () =>
|
||||
createNode({
|
||||
id: 'container',
|
||||
position: { x: 50, y: 70 },
|
||||
width: 500,
|
||||
height: 400,
|
||||
data: {
|
||||
type: BlockEnum.Iteration,
|
||||
title: 'Iteration',
|
||||
desc: '',
|
||||
width: 500,
|
||||
height: 400,
|
||||
selected: true,
|
||||
_children: [{ nodeId: 'child', nodeType: BlockEnum.Code }],
|
||||
},
|
||||
})
|
||||
|
||||
it('resizes both dimensions without moving the node and shrinks only as far as its contents allow', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkflowFlowComponent(<ResizableNode />, {
|
||||
nodes: [
|
||||
container(),
|
||||
createNode({
|
||||
id: 'child',
|
||||
parentId: 'container',
|
||||
position: { x: 100, y: 100 },
|
||||
width: 240,
|
||||
height: 100,
|
||||
}),
|
||||
],
|
||||
})
|
||||
const control = await screen.findByRole('button', { name: 'common.resize.node' })
|
||||
control.focus()
|
||||
await user.keyboard('{ArrowRight}{Shift>}{ArrowDown}{/Shift}')
|
||||
expect(screen.getByRole('status', { name: 'Node size' })).toHaveTextContent('508 × 432')
|
||||
expect(resize).toHaveBeenLastCalledWith(
|
||||
'container',
|
||||
expect.objectContaining({ x: 50, y: 70, width: 508, height: 432 }),
|
||||
)
|
||||
await user.keyboard('{Home}')
|
||||
expect(screen.getByRole('status', { name: 'Node size' })).toHaveTextContent('356 × 220')
|
||||
await user.keyboard('{ArrowLeft}{ArrowUp}')
|
||||
expect(screen.getByRole('status', { name: 'Node size' })).toHaveTextContent('356 × 220')
|
||||
await user.keyboard('{ArrowRight}{ArrowDown}{Enter}')
|
||||
expect(screen.getByRole('status', { name: 'Node size' })).toHaveTextContent('356 × 220')
|
||||
await user.keyboard('{End}')
|
||||
expect(screen.getByRole('status', { name: 'Node size' })).toHaveTextContent('356 × 220')
|
||||
})
|
||||
|
||||
it('keeps the resize action available when the node is not selected', async () => {
|
||||
const node = container()
|
||||
node.data.selected = false
|
||||
renderWorkflowFlowComponent(<ResizableNode />, { nodes: [node] })
|
||||
expect(await screen.findByRole('button', { name: 'common.resize.node' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('does not offer resizing on a readonly canvas', async () => {
|
||||
permissions.readonly = true
|
||||
renderWorkflowFlowComponent(<ResizableNode />, { nodes: [container()] })
|
||||
await screen.findByRole('status', { name: 'Node size' })
|
||||
expect(screen.queryByRole('button', { name: 'common.resize.node' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,9 +1,15 @@
|
||||
import type { OnResize } from 'reactflow'
|
||||
import type { CommonNodeType } from '../../../types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { NodeResizeControl } from 'reactflow'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { memo, useCallback, useId, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NodeResizeControl, useReactFlow } from 'reactflow'
|
||||
import { getKeyboardResizeValue } from '@/app/components/base/resize-handle/keyboard'
|
||||
import { ITERATION_PADDING, LOOP_PADDING } from '../../../constants'
|
||||
import { useNodesInteractions } from '../../../hooks/use-nodes-interactions'
|
||||
import { useNodesReadOnly } from '../../../hooks/use-workflow'
|
||||
import { BlockEnum } from '../../../types'
|
||||
|
||||
const Icon = () => {
|
||||
return (
|
||||
@ -35,27 +41,124 @@ const NodeResizer = ({
|
||||
minHeight = 152,
|
||||
maxWidth,
|
||||
}: NodeResizerProps) => {
|
||||
const { t } = useTranslation('common')
|
||||
const descriptionId = useId()
|
||||
const didDragRef = useRef(false)
|
||||
const { getNode, getNodes } = useReactFlow<CommonNodeType>()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { handleNodeResize } = useNodesInteractions()
|
||||
|
||||
const handleResize = useCallback<OnResize>(
|
||||
(_, params) => {
|
||||
didDragRef.current = true
|
||||
handleNodeResize(nodeId, params)
|
||||
},
|
||||
[nodeId, handleNodeResize],
|
||||
)
|
||||
|
||||
const resizeWithKeyboard = (
|
||||
event: Pick<React.KeyboardEvent, 'key' | 'shiftKey' | 'altKey' | 'ctrlKey' | 'metaKey'>,
|
||||
) => {
|
||||
if (nodesReadOnly) return false
|
||||
const node = getNode(nodeId)
|
||||
if (!node) return false
|
||||
|
||||
const padding = node.data.type === BlockEnum.Iteration ? ITERATION_PADDING : LOOP_PADDING
|
||||
const children = getNodes().filter((child) =>
|
||||
node.data._children?.some(({ nodeId }) => nodeId === child.id),
|
||||
)
|
||||
const requiredWidth = Math.max(
|
||||
minWidth,
|
||||
...children.map(
|
||||
(child) => child.position.x + (child.width ?? child.data.width ?? 0) + padding.right,
|
||||
),
|
||||
)
|
||||
const requiredHeight = Math.max(
|
||||
minHeight,
|
||||
...children.map(
|
||||
(child) => child.position.y + (child.height ?? child.data.height ?? 0) + padding.bottom,
|
||||
),
|
||||
)
|
||||
const width = node.data.width ?? node.width ?? minWidth
|
||||
const height = node.data.height ?? node.height ?? minHeight
|
||||
const nextWidth = getKeyboardResizeValue(event, {
|
||||
side: 'right',
|
||||
value: width,
|
||||
min: requiredWidth,
|
||||
max: maxWidth,
|
||||
})
|
||||
const nextHeight = getKeyboardResizeValue(event, {
|
||||
side: 'bottom',
|
||||
value: height,
|
||||
min: requiredHeight,
|
||||
})
|
||||
if (nextWidth === undefined && nextHeight === undefined) return false
|
||||
handleNodeResize(nodeId, {
|
||||
...node.position,
|
||||
width: nextWidth ?? width,
|
||||
height: nextHeight ?? height,
|
||||
direction: [nextWidth === undefined ? 0 : 1, nextHeight === undefined ? 0 : 1],
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (nodesReadOnly) return null
|
||||
|
||||
return (
|
||||
<div className={cn('hidden group-hover:block', nodeData.selected && 'block!')}>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100',
|
||||
nodeData.selected && 'pointer-events-auto opacity-100',
|
||||
)}
|
||||
>
|
||||
<NodeResizeControl
|
||||
nodeId={nodeId}
|
||||
position="bottom-right"
|
||||
className="border-none! bg-transparent!"
|
||||
onResize={handleResize}
|
||||
onResizeStart={() => {
|
||||
didDragRef.current = false
|
||||
}}
|
||||
minWidth={minWidth}
|
||||
minHeight={minHeight}
|
||||
maxWidth={maxWidth}
|
||||
>
|
||||
<div className="absolute right-px bottom-px">{icon}</div>
|
||||
<IconButton
|
||||
aria-label={t(($) => $['resize.node'])}
|
||||
aria-describedby={descriptionId}
|
||||
className="nodrag nopan absolute right-px bottom-px"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.detail > 0 && didDragRef.current) {
|
||||
didDragRef.current = false
|
||||
return
|
||||
}
|
||||
didDragRef.current = false
|
||||
resizeWithKeyboard({
|
||||
key: 'Home',
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
})
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
|
||||
if (!resizeWithKeyboard(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
</IconButton>
|
||||
</NodeResizeControl>
|
||||
<span id={descriptionId} className="sr-only" aria-live="polite">
|
||||
{t(($) => $['resize.size'], {
|
||||
width: Math.round(nodeData.width ?? minWidth),
|
||||
height: Math.round(nodeData.height ?? minHeight),
|
||||
})}{' '}
|
||||
{t(($) => $['resize.nodeHelp'])}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
|
||||
@ -788,7 +789,122 @@ describe('workflow-panel index', () => {
|
||||
expect(mockHandleStop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should persist user resize changes and compress oversized panel widths', async () => {
|
||||
it('should resize the node panel with the keyboard, persist its width, and allow focus to leave', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkflowComponent(
|
||||
<>
|
||||
<button>Before panel</button>
|
||||
<BasePanel id="node-resize" data={createData() as never}>
|
||||
<div>panel-child</div>
|
||||
</BasePanel>
|
||||
</>,
|
||||
{
|
||||
initialStoreState: {
|
||||
workflowCanvasWidth: 1200,
|
||||
nodePanelWidth: 480,
|
||||
otherPanelWidth: 200,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await user.tab()
|
||||
expect(screen.getByRole('button', { name: 'Before panel' })).toHaveFocus()
|
||||
await user.tab()
|
||||
const separator = screen.getByRole('separator', { name: 'workflow.panel.nodePanel' })
|
||||
expect(separator).toHaveFocus()
|
||||
expect(separator).toHaveAttribute('aria-orientation', 'vertical')
|
||||
const panel = document.getElementById(separator.getAttribute('aria-controls')!)!
|
||||
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
expect(panel).toHaveStyle({ width: '488px' })
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '488')
|
||||
await user.keyboard('{Shift>}{ArrowLeft}{/Shift}')
|
||||
expect(panel).toHaveStyle({ width: '520px' })
|
||||
await user.keyboard('{Shift>}{ArrowRight}{/Shift}{ArrowRight}')
|
||||
expect(panel).toHaveStyle({ width: '480px' })
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '480')
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('workflow-node-panel-width')).toBe('480')
|
||||
})
|
||||
|
||||
await user.tab()
|
||||
expect(separator).not.toHaveFocus()
|
||||
await user.tab({ shift: true })
|
||||
expect(separator).toHaveFocus()
|
||||
await user.tab({ shift: true })
|
||||
expect(screen.getByRole('button', { name: 'Before panel' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('should constrain keyboard resizing to the available space and keep unrelated keys untouched', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onKeyDown = vi.fn()
|
||||
const { store } = renderWorkflowComponent(
|
||||
<BasePanel id="node-resize" data={createData() as never}>
|
||||
<div>panel-child</div>
|
||||
</BasePanel>,
|
||||
{
|
||||
initialStoreState: {
|
||||
workflowCanvasWidth: 1200,
|
||||
nodePanelWidth: 480,
|
||||
otherPanelWidth: 200,
|
||||
},
|
||||
},
|
||||
)
|
||||
await user.tab()
|
||||
const separator = screen.getByRole('separator', { name: 'workflow.panel.nodePanel' })
|
||||
expect(separator).toHaveFocus()
|
||||
expect(separator).toHaveAttribute('aria-valuemin', '400')
|
||||
expect(separator).toHaveAttribute('aria-valuemax', '600')
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
try {
|
||||
await user.keyboard('{Home}{ArrowRight}')
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '400')
|
||||
await user.keyboard('{End}{ArrowLeft}')
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '600')
|
||||
expect(onKeyDown).not.toHaveBeenCalled()
|
||||
|
||||
act(() => store.setState({ otherPanelWidth: 300 }))
|
||||
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '500'))
|
||||
expect(separator).toHaveAttribute('aria-valuemax', '500')
|
||||
await user.keyboard('{Home}{End}')
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '500')
|
||||
|
||||
onKeyDown.mockClear()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '500')
|
||||
expect(onKeyDown).toHaveBeenCalledOnce()
|
||||
await user.keyboard('{Control>}{ArrowRight}{/Control}')
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '500')
|
||||
} finally {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
})
|
||||
|
||||
it('compresses the node panel when the preview grows without replacing the saved node width', async () => {
|
||||
localStorage.setItem('workflow-node-panel-width', '600')
|
||||
const { store } = renderWorkflowComponent(
|
||||
<BasePanel id="node-resize" data={createData() as never}>
|
||||
<div>panel-child</div>
|
||||
</BasePanel>,
|
||||
{
|
||||
initialStoreState: {
|
||||
workflowCanvasWidth: 1400,
|
||||
nodePanelWidth: 600,
|
||||
otherPanelWidth: 400,
|
||||
},
|
||||
},
|
||||
)
|
||||
const handle = screen.getByRole('separator', { name: 'workflow.panel.nodePanel' })
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '600')
|
||||
|
||||
act(() => store.getState().setOtherPanelWidth(600))
|
||||
|
||||
await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '400'))
|
||||
expect(handle).toHaveAttribute('aria-valuemax', '400')
|
||||
expect(localStorage.getItem('workflow-node-panel-width')).toBe('600')
|
||||
})
|
||||
|
||||
it('should compress oversized panel widths', async () => {
|
||||
const { container } = renderWorkflowComponent(
|
||||
<BasePanel id="node-resize" data={createData() as never}>
|
||||
<div>panel-child</div>
|
||||
|
||||
@ -9,11 +9,12 @@ import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import * as React from 'react'
|
||||
import { cloneElement, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cloneElement, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
import ResizeHandle from '@/app/components/base/resize-handle'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import {
|
||||
@ -94,6 +95,7 @@ type BasePanelProps = {
|
||||
|
||||
const BasePanel: FC<BasePanelProps> = ({ id, data, children }) => {
|
||||
const { t } = useTranslation()
|
||||
const panelId = useId()
|
||||
const language = useLanguage()
|
||||
const appId = useStore((s) => s.appId)
|
||||
const { data: userProfile } = useSuspenseQuery({
|
||||
@ -553,13 +555,21 @@ const BasePanel: FC<BasePanelProps> = ({ id, data, children }) => {
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
<ResizeHandle
|
||||
ref={triggerRef}
|
||||
className="absolute top-0 -left-1 flex h-full w-1 cursor-col-resize resize-x items-center justify-center"
|
||||
side="left"
|
||||
value={nodePanelWidth}
|
||||
min={400}
|
||||
max={maxNodePanelWidth}
|
||||
label={t(($) => $['panel.nodePanel'], { ns: 'workflow' })}
|
||||
controls={panelId}
|
||||
onResize={handleResize}
|
||||
className="absolute top-0 -left-1 flex h-full w-1 cursor-col-resize items-center justify-center"
|
||||
>
|
||||
<div className="h-10 w-0.5 rounded-xs bg-state-base-handle hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid"></div>
|
||||
</div>
|
||||
<div className="h-10 w-0.5 rounded-xs bg-state-base-handle group-focus-visible/resize:h-full group-focus-visible/resize:bg-state-accent-solid hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid"></div>
|
||||
</ResizeHandle>
|
||||
<Tabs
|
||||
id={panelId}
|
||||
ref={containerRef}
|
||||
value={tabType}
|
||||
onValueChange={(selectedValue) => setTabType(selectedValue)}
|
||||
|
||||
@ -43,7 +43,7 @@ const NoteNode = ({ id, data }: NodeProps<NoteNodeType>) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex flex-col rounded-md border shadow-xs hover:shadow-md',
|
||||
'group relative flex flex-col rounded-md border shadow-xs hover:shadow-md',
|
||||
THEME_MAP[theme]!.bg,
|
||||
data.selected ? THEME_MAP[theme]!.border : 'border-black/5',
|
||||
)}
|
||||
|
||||
@ -0,0 +1,148 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEffect } from 'react'
|
||||
import { ReactFlowProvider, useStoreApi } from 'reactflow'
|
||||
import SnippetRunPanel from '@/app/components/snippets/components/snippet-run-panel'
|
||||
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import DebugAndPreview from '../debug-and-preview'
|
||||
import Panel from '../index'
|
||||
import WorkflowPreview from '../workflow-preview'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-workflow-panel-interactions', () => ({
|
||||
useWorkflowInteractions: () => ({ handleCancelDebugAndPreviewPanel: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/app/components/workflow/hooks/use-workflow-run', () => ({
|
||||
useWorkflowRun: () => ({ handleRun: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/app/components/workflow/hooks/use-nodes-interactions-without-sync', () => ({
|
||||
useNodesInteractionsWithoutSync: () => ({ handleNodeCancelRunningStatus: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/app/components/workflow/hooks/use-edges-interactions-without-sync', () => ({
|
||||
useEdgesInteractionsWithoutSync: () => ({ handleEdgeCancelRunningStatus: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/app/components/base/chat/chat/check-input-forms-hooks', () => ({
|
||||
useCheckInputsForms: () => ({ checkInputsForm: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/app/components/workflow/panel/debug-and-preview/chat-wrapper', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({ default: () => null }))
|
||||
vi.mock('@/app/components/workflow/run/result-panel', () => ({ default: () => null }))
|
||||
vi.mock('@/app/components/workflow/run/result-text', () => ({ default: () => null }))
|
||||
vi.mock('@/app/components/workflow/panel/inputs-panel', () => ({ default: () => null }))
|
||||
vi.mock('@/app/components/workflow/panel/env-panel', () => ({ default: () => null }))
|
||||
// Node details do not own the preview width limit. Node-panel compression is
|
||||
// covered separately at the real BasePanel boundary.
|
||||
vi.mock('@/app/components/workflow/nodes', () => ({
|
||||
Panel: () => <div>Selected node details</div>,
|
||||
}))
|
||||
|
||||
function SelectedNodePanel({ children }: { children: ReactNode }) {
|
||||
const reactFlowStore = useStoreApi()
|
||||
useEffect(() => {
|
||||
reactFlowStore.getState().setNodes([createNode({ data: { selected: true } })])
|
||||
}, [reactFlowStore])
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const { getNodes, setNodes } = reactFlowStore.getState()
|
||||
setNodes(getNodes().map((node) => ({ ...node, data: { ...node.data, selected: false } })))
|
||||
}}
|
||||
>
|
||||
Deselect node
|
||||
</button>
|
||||
<Panel components={{ right: children }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPanels(children: ReactNode) {
|
||||
return renderWorkflowComponent(
|
||||
<ReactFlowProvider>
|
||||
<SelectedNodePanel>{children}</SelectedNodePanel>
|
||||
</ReactFlowProvider>,
|
||||
{
|
||||
initialStoreState: { workflowCanvasWidth: 1400, nodePanelWidth: 600, previewPanelWidth: 400 },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
describe('preview width limits inside the workflow Panel', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it.each([
|
||||
['workflow run', <WorkflowPreview key="workflow" />],
|
||||
['snippet run', <SnippetRunPanel key="snippet" fields={[]} />],
|
||||
['chat debug', <DebugAndPreview key="debug" />],
|
||||
])(
|
||||
'%s exposes the reachable maximum and restores canvas space after deselection',
|
||||
async (_name, content) => {
|
||||
const user = userEvent.setup()
|
||||
renderPanels(content)
|
||||
const handle = screen.getByRole('separator')
|
||||
expect(handle).toHaveAttribute('aria-valuemax', '600')
|
||||
await user.tab()
|
||||
await user.tab()
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{End}{ArrowLeft}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '600')
|
||||
const panel = document.getElementById(handle.getAttribute('aria-controls')!)!
|
||||
expect(panel).toHaveStyle({ width: '600px' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Deselect node' }))
|
||||
expect(screen.queryByText('Selected node details')).not.toBeInTheDocument()
|
||||
expect(handle).toHaveAttribute('aria-valuemax', '1000')
|
||||
await user.tab()
|
||||
await user.keyboard('{End}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '1000')
|
||||
expect(panel).toHaveStyle({ width: '1000px' })
|
||||
},
|
||||
)
|
||||
|
||||
it('allows the debug panel to grow by pointer while the selected node panel is wider than its minimum', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanels(<DebugAndPreview />)
|
||||
const handle = screen.getByRole('separator')
|
||||
await user.pointer([
|
||||
{ keys: '[MouseLeft>]', target: handle, coords: { clientX: 1000 } },
|
||||
{ target: handle, coords: { clientX: 800 } },
|
||||
{ keys: '[/MouseLeft]', target: handle },
|
||||
])
|
||||
await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '600'))
|
||||
expect(handle).toHaveAttribute('aria-valuemax', '600')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['workflow run', <WorkflowPreview key="workflow" />],
|
||||
['snippet run', <SnippetRunPanel key="snippet" fields={[]} />],
|
||||
['chat debug', <DebugAndPreview key="debug" />],
|
||||
])(
|
||||
'%s keeps its size and ARIA range consistent when the canvas shrinks without a selected node',
|
||||
async (_name, content) => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderPanels(content)
|
||||
await user.click(screen.getByRole('button', { name: 'Deselect node' }))
|
||||
const handle = screen.getByRole('separator')
|
||||
await user.tab()
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{End}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '1000')
|
||||
|
||||
act(() => store.getState().setWorkflowCanvasWidth(1000))
|
||||
|
||||
await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '600'))
|
||||
expect(handle).toHaveAttribute('aria-valuemax', '600')
|
||||
const panel = document.getElementById(handle.getAttribute('aria-controls')!)!
|
||||
expect(panel).toHaveStyle({ width: '600px' })
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '600')
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '592')
|
||||
},
|
||||
)
|
||||
})
|
||||
@ -0,0 +1,36 @@
|
||||
import { getPreviewPanelMaxWidth } from '../panel-width'
|
||||
|
||||
describe('getPreviewPanelMaxWidth', () => {
|
||||
it.each([undefined, 0])(
|
||||
'uses the caller fallback before canvas width is measured (%s)',
|
||||
(canvasWidth) => {
|
||||
expect(getPreviewPanelMaxWidth(canvasWidth, false)).toBe(1024)
|
||||
expect(getPreviewPanelMaxWidth(canvasWidth, true, 720)).toBe(720)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ hasSelectedNode: false, expected: 1000 },
|
||||
{ hasSelectedNode: true, expected: 600 },
|
||||
])(
|
||||
'reserves space for the canvas and an open node panel (selected: $hasSelectedNode)',
|
||||
({ hasSelectedNode, expected }) => {
|
||||
expect(getPreviewPanelMaxWidth(1400, hasSelectedNode)).toBe(expected)
|
||||
expect(getPreviewPanelMaxWidth(1400, hasSelectedNode, 720)).toBe(expected)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ canvasWidth: 600, hasSelectedNode: false, expected: 400 },
|
||||
{ canvasWidth: 800, hasSelectedNode: false, expected: 400 },
|
||||
{ canvasWidth: 801, hasSelectedNode: false, expected: 401 },
|
||||
{ canvasWidth: 1000, hasSelectedNode: true, expected: 400 },
|
||||
{ canvasWidth: 1200, hasSelectedNode: true, expected: 400 },
|
||||
{ canvasWidth: 1201, hasSelectedNode: true, expected: 401 },
|
||||
])(
|
||||
'keeps the preview usable on a $canvasWidth px canvas (selected: $hasSelectedNode)',
|
||||
({ canvasWidth, hasSelectedNode, expected }) => {
|
||||
expect(getPreviewPanelMaxWidth(canvasWidth, hasSelectedNode)).toBe(expected)
|
||||
},
|
||||
)
|
||||
})
|
||||
@ -5,15 +5,21 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { ReactFlowProvider } from 'reactflow'
|
||||
import {
|
||||
createNodeTracing,
|
||||
createWorkflowRunningData,
|
||||
} from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { renderWorkflowComponent as renderWithWorkflowStore } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { submitHumanInputForm } from '@/service/workflow'
|
||||
import WorkflowPreview from '../workflow-preview'
|
||||
|
||||
const renderWorkflowComponent = (
|
||||
ui: Parameters<typeof renderWithWorkflowStore>[0],
|
||||
options?: Parameters<typeof renderWithWorkflowStore>[1],
|
||||
) => renderWithWorkflowStore(<ReactFlowProvider>{ui}</ReactFlowProvider>, options)
|
||||
|
||||
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
|
||||
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
@ -175,6 +181,22 @@ describe('WorkflowPreview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resizes the run panel with the keyboard within the available canvas width', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkflowComponent(<WorkflowPreview />, {
|
||||
initialStoreState: { previewPanelWidth: 480, workflowCanvasWidth: 1000 },
|
||||
})
|
||||
await user.tab()
|
||||
const handle = screen.getByRole('separator', { name: 'workflow.singleRun.testRun' })
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{ArrowLeft}{Shift>}{ArrowLeft}{/Shift}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '520')
|
||||
await user.keyboard('{End}{ArrowLeft}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '600')
|
||||
await user.keyboard('{Home}{ArrowRight}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '400')
|
||||
})
|
||||
|
||||
it('should keep the input tab active, switch to result after running, and close the preview panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkflowComponent(<WorkflowPreview />, {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Ref } from 'react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useImperativeHandle } from 'react'
|
||||
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
@ -47,10 +47,6 @@ vi.mock('../../../nodes/_base/hooks/use-resize-panel', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../persistence/local-storage-options', () => ({
|
||||
useSetDebugPreviewPanelWidth: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../chat-wrapper', () => ({
|
||||
default: function MockChatWrapper({ ref }: { ref: Ref<{ handleRestart: () => void }> }) {
|
||||
useImperativeHandle(ref, () => ({ handleRestart: mockHandleRestart }))
|
||||
@ -63,6 +59,23 @@ describe('DebugAndPreview', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('resizes from the left edge with the keyboard and persists the width', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkflowComponent(<DebugAndPreview />, {
|
||||
initialStoreState: { previewPanelWidth: 480, workflowCanvasWidth: 1000 },
|
||||
})
|
||||
await user.tab()
|
||||
const handle = screen.getByRole('separator', { name: 'workflow.common.debugAndPreview' })
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '488')
|
||||
await waitFor(() => expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('488'))
|
||||
await user.keyboard('{End}{ArrowLeft}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '600')
|
||||
await user.keyboard('{Home}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '400')
|
||||
})
|
||||
|
||||
it('exposes and invokes the restart action by name', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkflowComponent(<DebugAndPreview />, {
|
||||
|
||||
@ -4,9 +4,10 @@ import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNodes } from 'reactflow'
|
||||
import ResizeHandle from '@/app/components/base/resize-handle'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { useEdgesInteractionsWithoutSync } from '../../hooks/use-edges-interactions-without-sync'
|
||||
import { useNodesInteractionsWithoutSync } from '../../hooks/use-nodes-interactions-without-sync'
|
||||
@ -14,6 +15,7 @@ import { useWorkflowInteractions } from '../../hooks/use-workflow-panel-interact
|
||||
import { useResizePanel } from '../../nodes/_base/hooks/use-resize-panel'
|
||||
import { useSetDebugPreviewPanelWidth } from '../../persistence/local-storage-options'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { getPreviewPanelMaxWidth } from '../panel-width'
|
||||
import ChatWrapper from './chat-wrapper'
|
||||
|
||||
export type ChatWrapperRefType = {
|
||||
@ -21,6 +23,7 @@ export type ChatWrapperRefType = {
|
||||
}
|
||||
const DebugAndPreview = () => {
|
||||
const { t } = useTranslation()
|
||||
const panelId = useId()
|
||||
const chatRef = useRef({ handleRestart: noop })
|
||||
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
|
||||
const { handleNodeCancelRunningStatus } = useNodesInteractionsWithoutSync()
|
||||
@ -44,7 +47,6 @@ const DebugAndPreview = () => {
|
||||
}
|
||||
|
||||
const workflowCanvasWidth = useStore((s) => s.workflowCanvasWidth)
|
||||
const nodePanelWidth = useStore((s) => s.nodePanelWidth)
|
||||
const panelWidth = useStore((s) => s.previewPanelWidth)
|
||||
const setPanelWidth = useStore((s) => s.setPreviewPanelWidth)
|
||||
const setPanelWidthStorage = useSetDebugPreviewPanelWidth()
|
||||
@ -55,13 +57,7 @@ const DebugAndPreview = () => {
|
||||
},
|
||||
[setPanelWidth, setPanelWidthStorage],
|
||||
)
|
||||
const maxPanelWidth = useMemo(() => {
|
||||
if (!workflowCanvasWidth) return 720
|
||||
|
||||
if (!selectedNode) return workflowCanvasWidth - 400
|
||||
|
||||
return workflowCanvasWidth - 400 - 400
|
||||
}, [workflowCanvasWidth, selectedNode, nodePanelWidth])
|
||||
const maxPanelWidth = getPreviewPanelMaxWidth(workflowCanvasWidth, !!selectedNode, 720)
|
||||
const { triggerRef, containerRef } = useResizePanel({
|
||||
direction: 'horizontal',
|
||||
triggerDirection: 'left',
|
||||
@ -74,13 +70,21 @@ const DebugAndPreview = () => {
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
<ResizeHandle
|
||||
ref={triggerRef}
|
||||
className="absolute top-0 -left-1 flex h-full w-1 cursor-col-resize resize-x items-center justify-center"
|
||||
side="left"
|
||||
value={panelWidth}
|
||||
min={400}
|
||||
max={maxPanelWidth}
|
||||
controls={panelId}
|
||||
label={t(($) => $['common.debugAndPreview'], { ns: 'workflow' })}
|
||||
onResize={(width) => handleResize(width, 'user')}
|
||||
className="absolute top-0 -left-1 flex h-full w-1 cursor-col-resize items-center justify-center"
|
||||
>
|
||||
<div className="h-10 w-0.5 rounded-xs bg-state-base-handle hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid"></div>
|
||||
</div>
|
||||
<div className="h-10 w-0.5 rounded-xs bg-state-base-handle group-focus-visible/resize:h-full group-focus-visible/resize:bg-state-accent-solid hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid"></div>
|
||||
</ResizeHandle>
|
||||
<div
|
||||
id={panelId}
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative flex h-full flex-col rounded-l-2xl border border-r-0 border-components-panel-border bg-chatbot-bg shadow-xl',
|
||||
|
||||
@ -8,6 +8,7 @@ import dynamic from '@/next/dynamic'
|
||||
import { Panel as NodePanel } from '../nodes'
|
||||
import { useStore } from '../store'
|
||||
import EnvPanel from './env-panel'
|
||||
import { getPreviewPanelMaxWidth } from './panel-width'
|
||||
|
||||
const VersionHistoryPanel = dynamic(
|
||||
() => import('@/app/components/workflow/panel/version-history-panel'),
|
||||
@ -102,19 +103,10 @@ const Panel: FC<PanelProps> = ({ components, versionHistoryPanelProps }) => {
|
||||
const previewPanelWidth = useStore((s) => s.previewPanelWidth)
|
||||
const setPreviewPanelWidth = useStore((s) => s.setPreviewPanelWidth)
|
||||
|
||||
// When a node is selected and the NodePanel appears, if the current width
|
||||
// of preview/otherPanel is too large, it may result in the total width of
|
||||
// the two panels exceeding the workflowCanvasWidth, causing the NodePanel
|
||||
// to be pushed out. Here we check and, if necessary, reduce the previewPanelWidth
|
||||
// to "workflowCanvasWidth - 400 (minimum NodePanel width) - 400 (minimum canvas space)",
|
||||
// while still ensuring that previewPanelWidth ≥ 400.
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedNode || !workflowCanvasWidth) return
|
||||
if (!workflowCanvasWidth) return
|
||||
|
||||
const reservedCanvasWidth = 400 // Reserve the minimum visible width for the canvas
|
||||
const minNodePanelWidth = 400
|
||||
const maxAllowed = Math.max(workflowCanvasWidth - reservedCanvasWidth - minNodePanelWidth, 400)
|
||||
const maxAllowed = getPreviewPanelMaxWidth(workflowCanvasWidth, !!selectedNode)
|
||||
|
||||
if (previewPanelWidth > maxAllowed) setPreviewPanelWidth(maxAllowed)
|
||||
}, [selectedNode, workflowCanvasWidth, previewPanelWidth, setPreviewPanelWidth])
|
||||
|
||||
11
web/app/components/workflow/panel/panel-width.ts
Normal file
11
web/app/components/workflow/panel/panel-width.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export const getPreviewPanelMaxWidth = (
|
||||
workflowCanvasWidth: number | undefined,
|
||||
hasSelectedNode: boolean,
|
||||
fallback = 1024,
|
||||
) => {
|
||||
if (!workflowCanvasWidth) return fallback
|
||||
|
||||
// Keep the canvas visible and allow an open node panel to shrink to its minimum.
|
||||
const reservedWidth = 400 + (hasSelectedNode ? 400 : 0)
|
||||
return Math.max(400, workflowCanvasWidth - reservedWidth)
|
||||
}
|
||||
@ -3,10 +3,12 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useReactFlowStore } from 'reactflow'
|
||||
import ReasoningPanel from '@/app/components/base/chat/chat/answer/reasoning-panel'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import ResizeHandle from '@/app/components/base/resize-handle'
|
||||
import { submitHumanInputForm } from '@/service/workflow'
|
||||
import { useWorkflowInteractions } from '../hooks/use-workflow-panel-interactions'
|
||||
import ResultPanel from '../run/result-panel'
|
||||
@ -18,9 +20,11 @@ import { formatWorkflowRunIdentifier } from '../utils'
|
||||
import HumanInputFilledFormList from './human-input-filled-form-list'
|
||||
import HumanInputFormList from './human-input-form-list'
|
||||
import InputsPanel from './inputs-panel'
|
||||
import { getPreviewPanelMaxWidth } from './panel-width'
|
||||
|
||||
const WorkflowPreview = () => {
|
||||
const { t } = useTranslation()
|
||||
const panelId = useId()
|
||||
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
|
||||
const workflowRunningData = useStore((s) => s.workflowRunningData)
|
||||
const isListening = useStore((s) => s.isListening)
|
||||
@ -28,6 +32,8 @@ const WorkflowPreview = () => {
|
||||
const workflowCanvasWidth = useStore((s) => s.workflowCanvasWidth)
|
||||
const panelWidth = useStore((s) => s.previewPanelWidth)
|
||||
const setPreviewPanelWidth = useStore((s) => s.setPreviewPanelWidth)
|
||||
const hasSelectedNode = useReactFlowStore((s) => s.getNodes().some((node) => node.data.selected))
|
||||
const maxPanelWidth = getPreviewPanelMaxWidth(workflowCanvasWidth, hasSelectedNode)
|
||||
const showDebugAndPreviewPanel = useStore((s) => s.showDebugAndPreviewPanel)
|
||||
const humanInputFormDataList = useStore((s) => s.workflowRunningData?.humanInputFormDataList)
|
||||
const humanInputFilledFormDataList = useStore(
|
||||
@ -75,14 +81,10 @@ const WorkflowPreview = () => {
|
||||
(e: MouseEvent) => {
|
||||
if (isResizing) {
|
||||
const newWidth = window.innerWidth - e.clientX
|
||||
// width constraints: 400 <= width <= maxAllowed (canvas - reserved 400)
|
||||
const reservedCanvasWidth = 400
|
||||
const maxAllowed = workflowCanvasWidth ? workflowCanvasWidth - reservedCanvasWidth : 1024
|
||||
|
||||
if (newWidth >= 400 && newWidth <= maxAllowed) setPreviewPanelWidth(newWidth)
|
||||
if (newWidth >= 400 && newWidth <= maxPanelWidth) setPreviewPanelWidth(newWidth)
|
||||
}
|
||||
},
|
||||
[isResizing, workflowCanvasWidth, setPreviewPanelWidth],
|
||||
[isResizing, maxPanelWidth, setPreviewPanelWidth],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@ -107,11 +109,19 @@ const WorkflowPreview = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
id={panelId}
|
||||
className="relative flex h-full flex-col rounded-l-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl"
|
||||
style={{ width: `${panelWidth}px` }}
|
||||
>
|
||||
<div
|
||||
className="absolute top-1/2 bottom-0 left-0.75 z-50 h-6 w-0.75 cursor-col-resize rounded-sm bg-gray-300"
|
||||
<ResizeHandle
|
||||
side="left"
|
||||
value={panelWidth}
|
||||
min={400}
|
||||
max={maxPanelWidth}
|
||||
controls={panelId}
|
||||
label={t(($) => $['singleRun.testRun'], { ns: 'workflow' })}
|
||||
onResize={setPreviewPanelWidth}
|
||||
className="absolute top-1/2 bottom-0 left-0.75 z-50 h-6 w-0.75 cursor-col-resize bg-state-base-handle"
|
||||
onMouseDown={startResizing}
|
||||
/>
|
||||
<div className="flex items-center justify-between p-4 pb-1 text-base font-semibold text-text-primary">
|
||||
|
||||
@ -1,7 +1,65 @@
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import VariableInspectPanel from '../index'
|
||||
|
||||
vi.mock('../panel', () => ({ default: () => <div>Variables</div> }))
|
||||
|
||||
describe('variable inspect index', () => {
|
||||
it('grows upward with the keyboard and persists its height', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkflowComponent(<VariableInspectPanel />, {
|
||||
initialStoreState: {
|
||||
showVariableInspectPanel: true,
|
||||
variableInspectPanelHeight: 200,
|
||||
workflowCanvasHeight: 500,
|
||||
},
|
||||
})
|
||||
await user.tab()
|
||||
const handle = screen.getByRole('separator', { name: 'workflow.debug.variableInspect.title' })
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{ArrowUp}{Shift>}{ArrowUp}{/Shift}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '240')
|
||||
await waitFor(() =>
|
||||
expect(localStorage.getItem('workflow-variable-inpsect-panel-height')).toBe('240'),
|
||||
)
|
||||
await user.keyboard('{End}{ArrowUp}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '440')
|
||||
await user.keyboard('{Home}{ArrowDown}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '120')
|
||||
})
|
||||
|
||||
it('constrains its height after the canvas shrinks without replacing the saved preference', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderWorkflowComponent(<VariableInspectPanel />, {
|
||||
initialStoreState: {
|
||||
showVariableInspectPanel: true,
|
||||
variableInspectPanelHeight: 200,
|
||||
workflowCanvasHeight: 500,
|
||||
},
|
||||
})
|
||||
await user.tab()
|
||||
const handle = screen.getByRole('separator', { name: 'workflow.debug.variableInspect.title' })
|
||||
await user.keyboard('{End}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '440')
|
||||
await waitFor(() =>
|
||||
expect(localStorage.getItem('workflow-variable-inpsect-panel-height')).toBe('440'),
|
||||
)
|
||||
|
||||
act(() => store.getState().setWorkflowCanvasHeight(300))
|
||||
|
||||
await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '240'))
|
||||
expect(handle).toHaveAttribute('aria-valuemax', '240')
|
||||
const panel = document.getElementById(handle.getAttribute('aria-controls')!)!
|
||||
expect(panel).toHaveStyle({ height: '240px' })
|
||||
expect(localStorage.getItem('workflow-variable-inpsect-panel-height')).toBe('440')
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{ArrowUp}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '240')
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '232')
|
||||
})
|
||||
|
||||
it('renders nothing when the inspect panel is hidden', () => {
|
||||
const { container } = renderWorkflowComponent(<VariableInspectPanel />, {
|
||||
initialStoreState: {
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useId, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ResizeHandle from '@/app/components/base/resize-handle'
|
||||
import { useResizePanel } from '../nodes/_base/hooks/use-resize-panel'
|
||||
import { useSetWorkflowVariableInspectPanelHeight } from '../persistence/local-storage-options'
|
||||
import { useStore } from '../store'
|
||||
import Panel from './panel'
|
||||
|
||||
const VariableInspectPanel: FC = () => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const panelId = useId()
|
||||
const showVariableInspectPanel = useStore((s) => s.showVariableInspectPanel)
|
||||
const workflowCanvasHeight = useStore((s) => s.workflowCanvasHeight)
|
||||
const variableInspectPanelHeight = useStore((s) => s.variableInspectPanelHeight)
|
||||
@ -15,9 +19,14 @@ const VariableInspectPanel: FC = () => {
|
||||
|
||||
const maxHeight = useMemo(() => {
|
||||
if (!workflowCanvasHeight) return 480
|
||||
return workflowCanvasHeight - 60
|
||||
return Math.max(120, workflowCanvasHeight - 60)
|
||||
}, [workflowCanvasHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (!workflowCanvasHeight) return
|
||||
if (variableInspectPanelHeight > maxHeight) setVariableInspectPanelHeight(maxHeight)
|
||||
}, [workflowCanvasHeight, variableInspectPanelHeight, maxHeight, setVariableInspectPanelHeight])
|
||||
|
||||
const setPanelHeightStorage = useSetWorkflowVariableInspectPanelHeight()
|
||||
|
||||
const handleResize = useCallback(
|
||||
@ -40,13 +49,21 @@ const VariableInspectPanel: FC = () => {
|
||||
|
||||
return (
|
||||
<div className={cn('relative pb-1')}>
|
||||
<div
|
||||
<ResizeHandle
|
||||
ref={triggerRef}
|
||||
className="absolute -top-1 left-0 flex h-1 w-full cursor-row-resize resize-y items-center justify-center"
|
||||
side="top"
|
||||
value={variableInspectPanelHeight}
|
||||
min={120}
|
||||
max={maxHeight}
|
||||
controls={panelId}
|
||||
label={t(($) => $['debug.variableInspect.title'])}
|
||||
onResize={(height) => handleResize(0, height)}
|
||||
className="absolute -top-1 left-0 flex h-1 w-full cursor-row-resize items-center justify-center"
|
||||
>
|
||||
<div className="h-0.5 w-10 rounded-xs bg-state-base-handle hover:w-full hover:bg-state-accent-solid active:w-full active:bg-state-accent-solid"></div>
|
||||
</div>
|
||||
<div className="h-0.5 w-10 rounded-xs bg-state-base-handle group-focus-visible/resize:w-full group-focus-visible/resize:bg-state-accent-solid hover:w-full hover:bg-state-accent-solid active:w-full active:bg-state-accent-solid"></div>
|
||||
</ResizeHandle>
|
||||
<div
|
||||
id={panelId}
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl',
|
||||
|
||||
@ -103,6 +103,7 @@ describe('SkillDetailPage navigation', () => {
|
||||
})
|
||||
|
||||
it('resizes the file tree sidebar within its accessible range', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
const resizeHandle = await screen.findByRole('separator', {
|
||||
@ -120,11 +121,14 @@ describe('SkillDetailPage navigation', () => {
|
||||
expect(resizeHandle).toHaveAttribute('aria-valuenow', '240')
|
||||
fireEvent.pointerUp(document)
|
||||
|
||||
fireEvent.keyDown(resizeHandle, { key: 'ArrowRight' })
|
||||
resizeHandle.focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(resizeHandle).toHaveAttribute('aria-valuenow', '248')
|
||||
fireEvent.keyDown(resizeHandle, { key: 'End' })
|
||||
await user.keyboard('{Shift>}{ArrowRight}{/Shift}')
|
||||
expect(resizeHandle).toHaveAttribute('aria-valuenow', '280')
|
||||
await user.keyboard('{End}')
|
||||
expect(resizeHandle).toHaveAttribute('aria-valuenow', '420')
|
||||
fireEvent.keyDown(resizeHandle, { key: 'Home' })
|
||||
await user.keyboard('{Home}')
|
||||
expect(resizeHandle).toHaveAttribute('aria-valuenow', '240')
|
||||
})
|
||||
|
||||
|
||||
@ -58,6 +58,7 @@ import copy from 'copy-to-clipboard'
|
||||
import { useCallback, useEffect, useEffectEvent, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { getKeyboardResizeValue } from '@/app/components/base/resize-handle/keyboard'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
|
||||
import AccountSection from '@/app/components/main-nav/components/account-section'
|
||||
@ -107,7 +108,6 @@ import {
|
||||
|
||||
const skillSidebarMinWidth = 240
|
||||
const skillSidebarMaxWidth = 420
|
||||
const skillSidebarKeyboardStep = 8
|
||||
|
||||
const skillSidebarHelpTriggerIcon = (
|
||||
<span aria-hidden className="i-ri-question-line size-4 shrink-0" />
|
||||
@ -176,6 +176,7 @@ export function FileTree({
|
||||
const queryClient = useQueryClient()
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const filesTitleId = useId()
|
||||
const sidebarPanelId = useId()
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
const [inlineAction, setInlineAction] = useState<FileTreeInlineAction>()
|
||||
const [draggingPaths, setDraggingPaths] = useState<string[]>([])
|
||||
@ -288,15 +289,17 @@ export function FileTree({
|
||||
)
|
||||
|
||||
const handleSidebarResizeKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
let nextWidth: number | undefined
|
||||
if (event.key === 'ArrowLeft') nextWidth = sidebarWidth - skillSidebarKeyboardStep
|
||||
if (event.key === 'ArrowRight') nextWidth = sidebarWidth + skillSidebarKeyboardStep
|
||||
if (event.key === 'Home') nextWidth = skillSidebarMinWidth
|
||||
if (event.key === 'End') nextWidth = skillSidebarMaxWidth
|
||||
const nextWidth = getKeyboardResizeValue(event, {
|
||||
side: 'right',
|
||||
value: sidebarWidth,
|
||||
min: skillSidebarMinWidth,
|
||||
max: skillSidebarMaxWidth,
|
||||
})
|
||||
if (nextWidth === undefined) return
|
||||
|
||||
event.preventDefault()
|
||||
setSidebarWidth(clampSkillSidebarWidth(nextWidth))
|
||||
event.stopPropagation()
|
||||
setSidebarWidth(nextWidth)
|
||||
}
|
||||
|
||||
const fileMutation = useMutation(
|
||||
@ -1157,6 +1160,7 @@ export function FileTree({
|
||||
onMouseLeave={collapsed ? closeSidebarFloatingPreview : undefined}
|
||||
>
|
||||
<div
|
||||
id={sidebarPanelId}
|
||||
data-testid="skill-detail-sidebar"
|
||||
className={cn(
|
||||
'group/sidebar relative flex min-h-0 flex-col rounded-lg bg-components-panel-bg',
|
||||
@ -1174,6 +1178,8 @@ export function FileTree({
|
||||
aria-valuemax={skillSidebarMaxWidth}
|
||||
aria-valuemin={skillSidebarMinWidth}
|
||||
aria-valuenow={sidebarWidth}
|
||||
aria-valuetext={t(($) => $['resize.width'], { ns: 'common', width: sidebarWidth })}
|
||||
aria-controls={sidebarPanelId}
|
||||
tabIndex={0}
|
||||
className="group/resize absolute top-0 -right-2 z-40 flex h-full w-4 cursor-col-resize touch-none items-center justify-center outline-hidden"
|
||||
onKeyDown={handleSidebarResizeKeyDown}
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": ".",
|
||||
"provider.encrypted.front": "سيتم تشفير مفتاح API الخاص بك وتخزينه باستخدام تقنية",
|
||||
"provider.validating": "جارٍ التحقق من المفتاح...",
|
||||
"resize.editor": "تغيير حجم المحرر",
|
||||
"resize.editorHelp": "استخدم السهمين لأعلى ولأسفل لتغيير الحجم. اضغط مطولاً على Shift لخطوات أكبر. يعيد Home أو Enter الحد الأدنى للارتفاع.",
|
||||
"resize.height": "الارتفاع {{height}} بكسل",
|
||||
"resize.node": "تغيير حجم العقدة",
|
||||
"resize.nodeHelp": "استخدم مفاتيح الأسهم لتغيير الحجم. اضغط مطولاً على Shift لخطوات أكبر. يصغّر Home أو Enter الحجم ليتسع للمحتوى.",
|
||||
"resize.size": "العرض {{width}} بكسل، الارتفاع {{height}} بكسل",
|
||||
"resize.width": "العرض {{width}} بكسل",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "الفوترة",
|
||||
"settings.customEndpoint": "نقطة نهاية مخصصة",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "العقدة غير موجودة: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "تم تحديد موقع العقدة: {{title}}",
|
||||
"panel.nextStep": "الخطوة التالية",
|
||||
"panel.nodePanel": "لوحة إعدادات العقدة",
|
||||
"panel.openWorkflow": "فتح سير العمل",
|
||||
"panel.optional": "(اختياري)",
|
||||
"panel.optional_and_hidden": "(اختياري ومخفي)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " Technologie gespeichert.",
|
||||
"provider.encrypted.front": "Ihr API-SCHLÜSSEL wird verschlüsselt und mit",
|
||||
"provider.validating": "Schlüssel wird validiert...",
|
||||
"resize.editor": "Editorgröße ändern",
|
||||
"resize.editorHelp": "Mit den Pfeiltasten nach oben und unten die Höhe ändern. Umschalt für größere Schritte halten. Pos1 oder Eingabe stellt die Mindesthöhe wieder her.",
|
||||
"resize.height": "Höhe: {{height}} Pixel",
|
||||
"resize.node": "Knotengröße ändern",
|
||||
"resize.nodeHelp": "Mit den Pfeiltasten die Größe ändern. Umschalt für größere Schritte halten. Pos1 oder Eingabe verkleinert auf die für den Inhalt benötigte Größe.",
|
||||
"resize.size": "{{width}} Pixel breit, {{height}} Pixel hoch",
|
||||
"resize.width": "Breite: {{width}} Pixel",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Abrechnung",
|
||||
"settings.customEndpoint": "Benutzerdefinierter Endpunkt",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Knoten nicht gefunden: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Knoten gefunden: {{title}}",
|
||||
"panel.nextStep": "Nächster Schritt",
|
||||
"panel.nodePanel": "Knotenkonfiguration",
|
||||
"panel.openWorkflow": "Workflow öffnen",
|
||||
"panel.optional": "(optional)",
|
||||
"panel.optional_and_hidden": "(optional & hidden)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " technology.",
|
||||
"provider.encrypted.front": "Your API KEY will be encrypted and stored using",
|
||||
"provider.validating": "Validating key...",
|
||||
"resize.editor": "Resize editor",
|
||||
"resize.editorHelp": "Use Up and Down to resize. Hold Shift for larger steps. Home or Enter resets to the minimum height.",
|
||||
"resize.height": "Height: {{height}} pixels",
|
||||
"resize.node": "Resize node",
|
||||
"resize.nodeHelp": "Use arrow keys to resize. Hold Shift for larger steps. Home or Enter shrinks to fit the contents.",
|
||||
"resize.size": "{{width}} pixels wide, {{height}} pixels high",
|
||||
"resize.width": "Width: {{width}} pixels",
|
||||
"settings.agentStrategy": "Agent Strategy",
|
||||
"settings.billing": "Billing",
|
||||
"settings.customEndpoint": "Custom Endpoint",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Node not found: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Located node: {{title}}",
|
||||
"panel.nextStep": "Next Step",
|
||||
"panel.nodePanel": "Node configuration panel",
|
||||
"panel.openWorkflow": "Open Workflow",
|
||||
"panel.optional": "(optional)",
|
||||
"panel.optional_and_hidden": "(optional & hidden)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " tecnología.",
|
||||
"provider.encrypted.front": "Tu CLAVE API será encriptada y almacenada usando",
|
||||
"provider.validating": "Validando clave...",
|
||||
"resize.editor": "Cambiar tamaño del editor",
|
||||
"resize.editorHelp": "Usa las flechas arriba y abajo para cambiar el tamaño. Mantén Mayús para pasos mayores. Inicio o Intro restablece la altura mínima.",
|
||||
"resize.height": "Alto: {{height}} píxeles",
|
||||
"resize.node": "Cambiar tamaño del nodo",
|
||||
"resize.nodeHelp": "Usa las flechas para cambiar el tamaño. Mantén Mayús para pasos mayores. Inicio o Intro reduce el tamaño para ajustarlo al contenido.",
|
||||
"resize.size": "{{width}} píxeles de ancho, {{height}} píxeles de alto",
|
||||
"resize.width": "Ancho: {{width}} píxeles",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Facturación",
|
||||
"settings.customEndpoint": "Endpoint personalizado",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Nodo no encontrado: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Nodo localizado: {{title}}",
|
||||
"panel.nextStep": "Siguiente paso",
|
||||
"panel.nodePanel": "Panel de configuración del nodo",
|
||||
"panel.openWorkflow": "Abrir flujo de trabajo",
|
||||
"panel.optional": "(opcional)",
|
||||
"panel.optional_and_hidden": "(opcional y oculto)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " رمزگذاری و ذخیره خواهد شد.",
|
||||
"provider.encrypted.front": "کلید API شما با استفاده از فناوری",
|
||||
"provider.validating": "در حال اعتبارسنجی کلید...",
|
||||
"resize.editor": "تغییر اندازهٔ ویرایشگر",
|
||||
"resize.editorHelp": "با کلیدهای جهت بالا و پایین اندازه را تغییر دهید. برای گامهای بزرگتر Shift را نگه دارید. Home یا Enter حداقل ارتفاع را بازمیگرداند.",
|
||||
"resize.height": "ارتفاع: {{height}} پیکسل",
|
||||
"resize.node": "تغییر اندازهٔ گره",
|
||||
"resize.nodeHelp": "با کلیدهای جهت اندازه را تغییر دهید. برای گامهای بزرگتر Shift را نگه دارید. Home یا Enter اندازه را تا حد لازم برای نمایش محتوا کوچک میکند.",
|
||||
"resize.size": "عرض {{width}} پیکسل، ارتفاع {{height}} پیکسل",
|
||||
"resize.width": "عرض: {{width}} پیکسل",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "صورتحساب",
|
||||
"settings.customEndpoint": "نقطه پایانی سفارشی",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "گره پیدا نشد: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "گره پیدا شد: {{title}}",
|
||||
"panel.nextStep": "مرحله بعدی",
|
||||
"panel.nodePanel": "پنل تنظیمات گره",
|
||||
"panel.openWorkflow": "باز کردن گردش کار",
|
||||
"panel.optional": "(اختیاری)",
|
||||
"panel.optional_and_hidden": "(اختیاری و مخفی)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": "technologie.",
|
||||
"provider.encrypted.front": "Votre clé API sera chiffrée et stockée en utilisant",
|
||||
"provider.validating": "Validation de la clé...",
|
||||
"resize.editor": "Redimensionner l’éditeur",
|
||||
"resize.editorHelp": "Utilisez les flèches haut et bas pour redimensionner. Maintenez Maj pour de plus grands pas. Début ou Entrée rétablit la hauteur minimale.",
|
||||
"resize.height": "Hauteur : {{height}} pixels",
|
||||
"resize.node": "Redimensionner le nœud",
|
||||
"resize.nodeHelp": "Utilisez les flèches pour redimensionner. Maintenez Maj pour de plus grands pas. Début ou Entrée réduit la taille au minimum nécessaire au contenu.",
|
||||
"resize.size": "{{width}} pixels de large, {{height}} pixels de haut",
|
||||
"resize.width": "Largeur : {{width}} pixels",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Facturation",
|
||||
"settings.customEndpoint": "Point de terminaison personnalisé",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Nœud introuvable : {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Nœud localisé : {{title}}",
|
||||
"panel.nextStep": "Étape suivante",
|
||||
"panel.nodePanel": "Panneau de configuration du nœud",
|
||||
"panel.openWorkflow": "Ouvrir le flux de travail",
|
||||
"panel.optional": "(facultatif)",
|
||||
"panel.optional_and_hidden": "(optionnel et caché)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " तकनीक का उपयोग करके।",
|
||||
"provider.encrypted.front": "आपकी एपीआई कुंजी को एन्क्रिप्ट किया जाएगा और संग्रहीत किया जाएगा",
|
||||
"provider.validating": "कुंजी का सत्यापन हो रहा है...",
|
||||
"resize.editor": "एडिटर का आकार बदलें",
|
||||
"resize.editorHelp": "आकार बदलने के लिए ऊपर और नीचे की तीर कुंजियाँ दबाएँ। बड़े बदलाव के लिए Shift दबाए रखें। Home या Enter न्यूनतम ऊँचाई बहाल करता है।",
|
||||
"resize.height": "ऊँचाई: {{height}} पिक्सेल",
|
||||
"resize.node": "नोड का आकार बदलें",
|
||||
"resize.nodeHelp": "आकार बदलने के लिए तीर कुंजियाँ दबाएँ। बड़े बदलाव के लिए Shift दबाए रखें। Home या Enter आकार को सामग्री समाने लायक न्यूनतम आकार तक घटाता है।",
|
||||
"resize.size": "चौड़ाई {{width}} पिक्सेल, ऊँचाई {{height}} पिक्सेल",
|
||||
"resize.width": "चौड़ाई: {{width}} पिक्सेल",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "बिलिंग",
|
||||
"settings.customEndpoint": "कस्टम एंडपॉइंट",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "नोड नहीं मिला: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "नोड मिला: {{title}}",
|
||||
"panel.nextStep": "अगला कदम",
|
||||
"panel.nodePanel": "नोड कॉन्फ़िगरेशन पैनल",
|
||||
"panel.openWorkflow": "वर्कफ़्लो खोलें",
|
||||
"panel.optional": "(वैकल्पिक)",
|
||||
"panel.optional_and_hidden": "(वैकल्पिक और छिपा हुआ)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": "Teknologi.",
|
||||
"provider.encrypted.front": "API KEY Anda akan dienkripsi dan disimpan menggunakan",
|
||||
"provider.validating": "Memvalidasi kunci...",
|
||||
"resize.editor": "Ubah ukuran editor",
|
||||
"resize.editorHelp": "Gunakan panah atas dan bawah untuk mengubah ukuran. Tahan Shift untuk langkah lebih besar. Home atau Enter mengembalikan tinggi minimum.",
|
||||
"resize.height": "Tinggi: {{height}} piksel",
|
||||
"resize.node": "Ubah ukuran node",
|
||||
"resize.nodeHelp": "Gunakan tombol panah untuk mengubah ukuran. Tahan Shift untuk langkah lebih besar. Home atau Enter memperkecil ukuran agar pas dengan konten.",
|
||||
"resize.size": "Lebar {{width}} piksel, tinggi {{height}} piksel",
|
||||
"resize.width": "Lebar: {{width}} piksel",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Penagihan",
|
||||
"settings.customEndpoint": "Endpoint Kustom",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Node tidak ditemukan: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Node ditemukan: {{title}}",
|
||||
"panel.nextStep": "Langkah Berikutnya",
|
||||
"panel.nodePanel": "Panel konfigurasi node",
|
||||
"panel.openWorkflow": "Buka Alur Kerja",
|
||||
"panel.optional": "(opsional)",
|
||||
"panel.optional_and_hidden": "(opsional & tersembunyi)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": ".",
|
||||
"provider.encrypted.front": "La tua API KEY sarà crittografata e archiviata utilizzando la tecnologia",
|
||||
"provider.validating": "Convalida chiave in corso...",
|
||||
"resize.editor": "Ridimensiona editor",
|
||||
"resize.editorHelp": "Usa le frecce su e giù per ridimensionare. Tieni premuto Maiusc per incrementi maggiori. Home o Invio ripristina l’altezza minima.",
|
||||
"resize.height": "Altezza: {{height}} pixel",
|
||||
"resize.node": "Ridimensiona nodo",
|
||||
"resize.nodeHelp": "Usa le frecce per ridimensionare. Tieni premuto Maiusc per incrementi maggiori. Home o Invio riduce le dimensioni adattandole al contenuto.",
|
||||
"resize.size": "{{width}} pixel di larghezza, {{height}} pixel di altezza",
|
||||
"resize.width": "Larghezza: {{width}} pixel",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Fatturazione",
|
||||
"settings.customEndpoint": "Endpoint personalizzato",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Nodo non trovato: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Nodo localizzato: {{title}}",
|
||||
"panel.nextStep": "Prossimo Passo",
|
||||
"panel.nodePanel": "Pannello di configurazione del nodo",
|
||||
"panel.openWorkflow": "Apri flusso di lavoro",
|
||||
"panel.optional": "(opzionale)",
|
||||
"panel.optional_and_hidden": "(opzionale e nascosto)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": "技術を使用して暗号化および保存されます。",
|
||||
"provider.encrypted.front": "API KEY は",
|
||||
"provider.validating": "キーの検証中...",
|
||||
"resize.editor": "エディターのサイズを変更",
|
||||
"resize.editorHelp": "上下の矢印キーで高さを変更します。Shift を押しながら操作すると変更幅が大きくなります。Home または Enter で最小の高さに戻します。",
|
||||
"resize.height": "高さ:{{height}}ピクセル",
|
||||
"resize.node": "ノードのサイズを変更",
|
||||
"resize.nodeHelp": "矢印キーでサイズを変更します。Shift を押しながら操作すると変更幅が大きくなります。Home または Enter で内容が収まる最小サイズに縮小します。",
|
||||
"resize.size": "幅 {{width}} ピクセル、高さ {{height}} ピクセル",
|
||||
"resize.width": "幅:{{width}}ピクセル",
|
||||
"settings.agentStrategy": "エージェント戦略",
|
||||
"settings.billing": "請求",
|
||||
"settings.customEndpoint": "カスタム API",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "ノードが見つかりません:{{nodeId}}",
|
||||
"panel.locateNodeSuccess": "ノードを特定しました:{{title}}",
|
||||
"panel.nextStep": "次のステップ",
|
||||
"panel.nodePanel": "ノード設定パネル",
|
||||
"panel.openWorkflow": "ワークフローを開く",
|
||||
"panel.optional": "(任意)",
|
||||
"panel.optional_and_hidden": "(オプションおよび非表示)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": "기술을 사용하여 암호화 및 저장됩니다.",
|
||||
"provider.encrypted.front": "API KEY 는",
|
||||
"provider.validating": "키를 확인하는 중...",
|
||||
"resize.editor": "편집기 크기 조절",
|
||||
"resize.editorHelp": "위아래 방향키로 높이를 조절합니다. Shift를 누르면 조절 폭이 커집니다. Home 또는 Enter로 최소 높이로 되돌립니다.",
|
||||
"resize.height": "높이: {{height}}픽셀",
|
||||
"resize.node": "노드 크기 조절",
|
||||
"resize.nodeHelp": "방향키로 크기를 조절합니다. Shift를 누르면 조절 폭이 커집니다. Home 또는 Enter로 내용에 맞는 최소 크기로 줄입니다.",
|
||||
"resize.size": "너비 {{width}}픽셀, 높이 {{height}}픽셀",
|
||||
"resize.width": "너비: {{width}}픽셀",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "청구",
|
||||
"settings.customEndpoint": "사용자 지정 엔드포인트",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "노드를 찾을 수 없습니다: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "노드를 찾았습니다: {{title}}",
|
||||
"panel.nextStep": "다음 단계",
|
||||
"panel.nodePanel": "노드 설정 패널",
|
||||
"panel.openWorkflow": "워크플로 열기",
|
||||
"panel.optional": "(선택사항)",
|
||||
"panel.optional_and_hidden": "(선택 사항 및 숨김)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " ເຕັກໂນໂລຊີ.",
|
||||
"provider.encrypted.front": "API KEY ຂອງທ່ານຈະຖືກເຂົ້າລະຫັດ ແລະ ເກັບຮັກສາໂດຍໃຊ້",
|
||||
"provider.validating": "ກຳລັງກວດສອບ Key...",
|
||||
"resize.editor": "ປັບຂະໜາດຕົວແກ້ໄຂ",
|
||||
"resize.editorHelp": "ໃຊ້ປຸ່ມລູກສອນຂຶ້ນ ແລະ ລົງເພື່ອປັບຂະໜາດ. ກົດ Shift ຄ້າງໄວ້ເພື່ອປັບເທື່ອລະຫຼາຍຂຶ້ນ. Home ຫຼື Enter ຈະຄືນຄ່າຄວາມສູງຕ່ຳສຸດ.",
|
||||
"resize.height": "ຄວາມສູງ: {{height}} ພິກເຊລ",
|
||||
"resize.node": "ປັບຂະໜາດໂນດ",
|
||||
"resize.nodeHelp": "ໃຊ້ປຸ່ມລູກສອນເພື່ອປັບຂະໜາດ. ກົດ Shift ຄ້າງໄວ້ເພື່ອປັບເທື່ອລະຫຼາຍຂຶ້ນ. Home ຫຼື Enter ຈະຫຍໍ້ໃຫ້ພໍດີກັບເນື້ອຫາ.",
|
||||
"resize.size": "ກວ້າງ {{width}} ພິກເຊລ, ສູງ {{height}} ພິກເຊລ",
|
||||
"resize.width": "ຄວາມກວ້າງ: {{width}} ພິກເຊລ",
|
||||
"settings.agentStrategy": "ກົນລະຍຸດຕົວແທນ",
|
||||
"settings.billing": "ການຊຳລະເງິນ",
|
||||
"settings.customEndpoint": "ຈຸດເຊື່ອມຕໍ່ແບບກຳນົດເອງ",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "ບໍ່ພົບ node: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "ທີ່ຢູ່: {{title}}",
|
||||
"panel.nextStep": "ຂັ້ນຕອນຖັດໄປ",
|
||||
"panel.nodePanel": "ແຜງຕັ້ງຄ່າໂນດ",
|
||||
"panel.openWorkflow": "ເປີດ Workflow",
|
||||
"panel.optional": "(ເລືອກໄດ້)",
|
||||
"panel.optional_and_hidden": "(ເລືອກໄດ້ & ເຊື່ອງໄວ້)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " technology.",
|
||||
"provider.encrypted.front": "Your API KEY will be encrypted and stored using",
|
||||
"provider.validating": "Validating key...",
|
||||
"resize.editor": "Editorformaat wijzigen",
|
||||
"resize.editorHelp": "Gebruik de pijlen omhoog en omlaag om het formaat te wijzigen. Houd Shift ingedrukt voor grotere stappen. Home of Enter herstelt de minimale hoogte.",
|
||||
"resize.height": "Hoogte: {{height}} pixels",
|
||||
"resize.node": "Knooppuntformaat wijzigen",
|
||||
"resize.nodeHelp": "Gebruik de pijltjestoetsen om het formaat te wijzigen. Houd Shift ingedrukt voor grotere stappen. Home of Enter verkleint het formaat tot de inhoud past.",
|
||||
"resize.size": "{{width}} pixels breed, {{height}} pixels hoog",
|
||||
"resize.width": "Breedte: {{width}} pixels",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Billing",
|
||||
"settings.customEndpoint": "Aangepast eindpunt",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Node niet gevonden: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Node gevonden: {{title}}",
|
||||
"panel.nextStep": "Next Step",
|
||||
"panel.nodePanel": "Configuratiepaneel voor knooppunten",
|
||||
"panel.openWorkflow": "Open Workflow",
|
||||
"panel.optional": "(optional)",
|
||||
"panel.optional_and_hidden": "(optional & hidden)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " technologii.",
|
||||
"provider.encrypted.front": "Twój KLUCZ API będzie szyfrowany i przechowywany za pomocą",
|
||||
"provider.validating": "Weryfikowanie klucza...",
|
||||
"resize.editor": "Zmień rozmiar edytora",
|
||||
"resize.editorHelp": "Użyj strzałek w górę i w dół, aby zmienić rozmiar. Przytrzymaj Shift, aby zwiększyć krok. Home lub Enter przywraca minimalną wysokość.",
|
||||
"resize.height": "Wysokość: {{height}} pikseli",
|
||||
"resize.node": "Zmień rozmiar węzła",
|
||||
"resize.nodeHelp": "Użyj strzałek, aby zmienić rozmiar. Przytrzymaj Shift, aby zwiększyć krok. Home lub Enter zmniejsza rozmiar do wymiarów mieszczących zawartość.",
|
||||
"resize.size": "Szerokość {{width}} pikseli, wysokość {{height}} pikseli",
|
||||
"resize.width": "Szerokość: {{width}} pikseli",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Rozliczenia",
|
||||
"settings.customEndpoint": "Niestandardowy punkt końcowy",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Nie znaleziono węzła: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Zlokalizowano węzeł: {{title}}",
|
||||
"panel.nextStep": "Następny krok",
|
||||
"panel.nodePanel": "Panel konfiguracji węzła",
|
||||
"panel.openWorkflow": "Otwórz przepływ pracy",
|
||||
"panel.optional": "(opcjonalne)",
|
||||
"panel.optional_and_hidden": "(opcjonalne i ukryte)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " tecnologia.",
|
||||
"provider.encrypted.front": "Sua CHAVE DA API será criptografada e armazenada usando",
|
||||
"provider.validating": "Validando chave...",
|
||||
"resize.editor": "Redimensionar editor",
|
||||
"resize.editorHelp": "Use as setas para cima e para baixo para redimensionar. Segure Shift para passos maiores. Home ou Enter restaura a altura mínima.",
|
||||
"resize.height": "Altura: {{height}} pixels",
|
||||
"resize.node": "Redimensionar nó",
|
||||
"resize.nodeHelp": "Use as setas para redimensionar. Segure Shift para passos maiores. Home ou Enter reduz o tamanho para acomodar o conteúdo.",
|
||||
"resize.size": "{{width}} pixels de largura, {{height}} pixels de altura",
|
||||
"resize.width": "Largura: {{width}} pixels",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Faturamento",
|
||||
"settings.customEndpoint": "Endpoint personalizado",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Nó não encontrado: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Nó localizado: {{title}}",
|
||||
"panel.nextStep": "Próximo passo",
|
||||
"panel.nodePanel": "Painel de configuração do nó",
|
||||
"panel.openWorkflow": "Abrir fluxo de trabalho",
|
||||
"panel.optional": "(opcional)",
|
||||
"panel.optional_and_hidden": "(opcional & oculto)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " tehnologie.",
|
||||
"provider.encrypted.front": "Cheia dvs. API va fi criptată și stocată folosind",
|
||||
"provider.validating": "Se validează cheia...",
|
||||
"resize.editor": "Redimensionează editorul",
|
||||
"resize.editorHelp": "Folosește săgețile sus și jos pentru redimensionare. Ține apăsat Shift pentru pași mai mari. Home sau Enter restabilește înălțimea minimă.",
|
||||
"resize.height": "Înălțime: {{height}} pixeli",
|
||||
"resize.node": "Redimensionează nodul",
|
||||
"resize.nodeHelp": "Folosește săgețile pentru redimensionare. Ține apăsat Shift pentru pași mai mari. Home sau Enter micșorează dimensiunea cât să încapă conținutul.",
|
||||
"resize.size": "{{width}} pixeli lățime, {{height}} pixeli înălțime",
|
||||
"resize.width": "Lățime: {{width}} pixeli",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Facturare",
|
||||
"settings.customEndpoint": "Endpoint personalizat",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Nod negăsit: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Nod localizat: {{title}}",
|
||||
"panel.nextStep": "Pasul următor",
|
||||
"panel.nodePanel": "Panoul de configurare a nodului",
|
||||
"panel.openWorkflow": "Deschide fluxul de lucru",
|
||||
"panel.optional": "(opțional)",
|
||||
"panel.optional_and_hidden": "(opțional și ascuns)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " технологии.",
|
||||
"provider.encrypted.front": "Ваш API-ключ будет зашифрован и сохранен с использованием",
|
||||
"provider.validating": "Проверка ключа...",
|
||||
"resize.editor": "Изменить размер редактора",
|
||||
"resize.editorHelp": "Меняйте высоту стрелками вверх и вниз. Удерживайте Shift для большего шага. Home или Enter возвращает минимальную высоту.",
|
||||
"resize.height": "Высота: {{height}} пикселей",
|
||||
"resize.node": "Изменить размер узла",
|
||||
"resize.nodeHelp": "Меняйте размер стрелками. Удерживайте Shift для большего шага. Home или Enter уменьшает размер до минимального, вмещающего содержимое.",
|
||||
"resize.size": "Ширина {{width}} пикселей, высота {{height}} пикселей",
|
||||
"resize.width": "Ширина: {{width}} пикселей",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Оплата",
|
||||
"settings.customEndpoint": "Пользовательская конечная точка",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Узел не найден: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Узел найден: {{title}}",
|
||||
"panel.nextStep": "Следующий шаг",
|
||||
"panel.nodePanel": "Панель настройки узла",
|
||||
"panel.openWorkflow": "Открыть рабочий процесс",
|
||||
"panel.optional": "(необязательно)",
|
||||
"panel.optional_and_hidden": "(необязательно и скрыто)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " tehnologije.",
|
||||
"provider.encrypted.front": "Vaš API ključ bo šifriran in shranjen z uporabo",
|
||||
"provider.validating": "Preverjam ključ...",
|
||||
"resize.editor": "Spremeni velikost urejevalnika",
|
||||
"resize.editorHelp": "S puščicama gor in dol spremenite velikost. Za večje korake pridržite Shift. Home ali Enter povrne najmanjšo višino.",
|
||||
"resize.height": "Višina: {{height}} slikovnih pik",
|
||||
"resize.node": "Spremeni velikost vozlišča",
|
||||
"resize.nodeHelp": "S puščičnimi tipkami spremenite velikost. Za večje korake pridržite Shift. Home ali Enter zmanjša velikost tako, da se vsebina še prilega.",
|
||||
"resize.size": "Širina {{width}} slikovnih pik, višina {{height}} slikovnih pik",
|
||||
"resize.width": "Širina: {{width}} slikovnih pik",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Zaračunavanje",
|
||||
"settings.customEndpoint": "Končna točka po meri",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Vozlišče ni najdeno: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Vozlišče najdeno: {{title}}",
|
||||
"panel.nextStep": "Naslednji korak",
|
||||
"panel.nodePanel": "Podokno za nastavitev vozlišča",
|
||||
"panel.openWorkflow": "Odpri delovni tok",
|
||||
"panel.optional": "(neobvezno)",
|
||||
"panel.optional_and_hidden": "(neobvezno in skrito)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": "เทคโนโลยี ",
|
||||
"provider.encrypted.front": "คีย์ API ของคุณจะถูกเข้ารหัสและจัดเก็บโดยใช้",
|
||||
"provider.validating": "กําลังตรวจสอบความถูกต้องของคีย์...",
|
||||
"resize.editor": "ปรับขนาดตัวแก้ไข",
|
||||
"resize.editorHelp": "ใช้ปุ่มลูกศรขึ้นและลงเพื่อปรับขนาด กด Shift ค้างไว้เพื่อปรับทีละมากขึ้น Home หรือ Enter จะคืนค่าความสูงต่ำสุด",
|
||||
"resize.height": "ความสูง: {{height}} พิกเซล",
|
||||
"resize.node": "ปรับขนาดโหนด",
|
||||
"resize.nodeHelp": "ใช้ปุ่มลูกศรเพื่อปรับขนาด กด Shift ค้างไว้เพื่อปรับทีละมากขึ้น Home หรือ Enter จะย่อให้พอดีกับเนื้อหา",
|
||||
"resize.size": "กว้าง {{width}} พิกเซล สูง {{height}} พิกเซล",
|
||||
"resize.width": "ความกว้าง: {{width}} พิกเซล",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "เรียก เก็บ เงิน",
|
||||
"settings.customEndpoint": "ปลายทางแบบกำหนดเอง",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "ไม่พบโหนด: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "พบโหนดแล้ว: {{title}}",
|
||||
"panel.nextStep": "ขั้นตอนถัดไป",
|
||||
"panel.nodePanel": "แผงการตั้งค่าโหนด",
|
||||
"panel.openWorkflow": "เปิดเวิร์กโฟลว์",
|
||||
"panel.optional": "(ไม่บังคับ)",
|
||||
"panel.optional_and_hidden": "(ตัวเลือก & ซ่อน)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " teknolojisi.",
|
||||
"provider.encrypted.front": "API ANAHTARINIZ şu kullanılarak şifrelenip saklanacak:",
|
||||
"provider.validating": "Anahtar doğrulanıyor...",
|
||||
"resize.editor": "Düzenleyiciyi yeniden boyutlandır",
|
||||
"resize.editorHelp": "Boyutlandırmak için yukarı ve aşağı oklarını kullanın. Daha büyük adımlar için Shift tuşunu basılı tutun. Home veya Enter en küçük yüksekliğe döndürür.",
|
||||
"resize.height": "Yükseklik: {{height}} piksel",
|
||||
"resize.node": "Düğümü yeniden boyutlandır",
|
||||
"resize.nodeHelp": "Boyutlandırmak için ok tuşlarını kullanın. Daha büyük adımlar için Shift tuşunu basılı tutun. Home veya Enter içeriği sığdıracak en küçük boyuta küçültür.",
|
||||
"resize.size": "{{width}} piksel genişlik, {{height}} piksel yükseklik",
|
||||
"resize.width": "Genişlik: {{width}} piksel",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Faturalandırma",
|
||||
"settings.customEndpoint": "Özel Uç Nokta",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Düğüm bulunamadı: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Düğüm bulundu: {{title}}",
|
||||
"panel.nextStep": "Sonraki Adım",
|
||||
"panel.nodePanel": "Düğüm yapılandırma paneli",
|
||||
"panel.openWorkflow": "İş Akışını Aç",
|
||||
"panel.optional": "(isteğe bağlı)",
|
||||
"panel.optional_and_hidden": "(isteğe bağlı ve gizli)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " технології.",
|
||||
"provider.encrypted.front": "Ваш API-ключ буде зашифрований та збережений за допомогою",
|
||||
"provider.validating": "Перевірка ключа...",
|
||||
"resize.editor": "Змінити розмір редактора",
|
||||
"resize.editorHelp": "Змінюйте висоту стрілками вгору та вниз. Утримуйте Shift для більшого кроку. Home або Enter відновлює мінімальну висоту.",
|
||||
"resize.height": "Висота: {{height}} пікселів",
|
||||
"resize.node": "Змінити розмір вузла",
|
||||
"resize.nodeHelp": "Змінюйте розмір стрілками. Утримуйте Shift для більшого кроку. Home або Enter зменшує розмір до мінімального, що вміщує вміст.",
|
||||
"resize.size": "Ширина {{width}} пікселів, висота {{height}} пікселів",
|
||||
"resize.width": "Ширина: {{width}} пікселів",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Виставлення рахунків",
|
||||
"settings.customEndpoint": "Користувацька кінцева точка",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Вузол не знайдено: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Вузол знайдено: {{title}}",
|
||||
"panel.nextStep": "Наступний крок",
|
||||
"panel.nodePanel": "Панель налаштування вузла",
|
||||
"panel.openWorkflow": "Відкрити робочий процес",
|
||||
"panel.optional": "(необов'язково)",
|
||||
"panel.optional_and_hidden": "(необов'язково & приховано)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " công nghệ.",
|
||||
"provider.encrypted.front": "Khóa API của bạn sẽ được mã hóa và lưu trữ bằng",
|
||||
"provider.validating": "Đang xác minh khóa...",
|
||||
"resize.editor": "Đổi kích thước trình soạn thảo",
|
||||
"resize.editorHelp": "Dùng phím mũi tên lên và xuống để đổi kích thước. Giữ Shift để tăng bước điều chỉnh. Home hoặc Enter khôi phục chiều cao tối thiểu.",
|
||||
"resize.height": "Chiều cao: {{height}} pixel",
|
||||
"resize.node": "Đổi kích thước nút",
|
||||
"resize.nodeHelp": "Dùng các phím mũi tên để đổi kích thước. Giữ Shift để tăng bước điều chỉnh. Home hoặc Enter thu nhỏ vừa đủ chứa nội dung.",
|
||||
"resize.size": "Rộng {{width}} pixel, cao {{height}} pixel",
|
||||
"resize.width": "Chiều rộng: {{width}} pixel",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Thanh toán",
|
||||
"settings.customEndpoint": "Điểm cuối tùy chỉnh",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "Không tìm thấy nút: {{nodeId}}",
|
||||
"panel.locateNodeSuccess": "Đã định vị nút: {{title}}",
|
||||
"panel.nextStep": "Bước tiếp theo",
|
||||
"panel.nodePanel": "Bảng cấu hình nút",
|
||||
"panel.openWorkflow": "Mở quy trình làm việc",
|
||||
"panel.optional": "(tùy chọn)",
|
||||
"panel.optional_and_hidden": "(tùy chọn & ẩn)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " 技术进行加密和存储。",
|
||||
"provider.encrypted.front": "密钥将使用 ",
|
||||
"provider.validating": "验证密钥中...",
|
||||
"resize.editor": "调整编辑器尺寸",
|
||||
"resize.editorHelp": "使用上下方向键调整高度,按住 Shift 加速。Home 或 Enter 恢复最小高度。",
|
||||
"resize.height": "高度:{{height}} 像素",
|
||||
"resize.node": "调整节点尺寸",
|
||||
"resize.nodeHelp": "使用方向键调整尺寸,按住 Shift 加速。Home 或 Enter 缩小至容纳内容的最小尺寸。",
|
||||
"resize.size": "宽 {{width}} 像素,高 {{height}} 像素",
|
||||
"resize.width": "宽度:{{width}} 像素",
|
||||
"settings.agentStrategy": "Agent 策略",
|
||||
"settings.billing": "账单",
|
||||
"settings.customEndpoint": "自定义端点",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "未找到节点:{{nodeId}}",
|
||||
"panel.locateNodeSuccess": "已定位到节点:{{title}}",
|
||||
"panel.nextStep": "下一步",
|
||||
"panel.nodePanel": "节点配置面板",
|
||||
"panel.openWorkflow": "打开工作流",
|
||||
"panel.optional": "(选填)",
|
||||
"panel.optional_and_hidden": "(选填 & 隐藏)",
|
||||
|
||||
@ -544,6 +544,13 @@
|
||||
"provider.encrypted.back": " 技術進行加密和儲存。",
|
||||
"provider.encrypted.front": "金鑰將使用 ",
|
||||
"provider.validating": "驗證金鑰中...",
|
||||
"resize.editor": "調整編輯器尺寸",
|
||||
"resize.editorHelp": "使用上下方向鍵調整高度,按住 Shift 加速。Home 或 Enter 恢復最小高度。",
|
||||
"resize.height": "高度:{{height}} 像素",
|
||||
"resize.node": "調整節點尺寸",
|
||||
"resize.nodeHelp": "使用方向鍵調整尺寸,按住 Shift 加速。Home 或 Enter 縮小至容納內容的最小尺寸。",
|
||||
"resize.size": "寬 {{width}} 像素,高 {{height}} 像素",
|
||||
"resize.width": "寬度:{{width}} 像素",
|
||||
"settings.agentStrategy": "Agent 策略",
|
||||
"settings.billing": "賬單",
|
||||
"settings.customEndpoint": "自訂端點",
|
||||
|
||||
@ -998,6 +998,7 @@
|
||||
"panel.locateNodeNotFound": "未找到節點:{{nodeId}}",
|
||||
"panel.locateNodeSuccess": "已定位到節點:{{title}}",
|
||||
"panel.nextStep": "下一步",
|
||||
"panel.nodePanel": "節點設定面板",
|
||||
"panel.openWorkflow": "打開工作流程",
|
||||
"panel.optional": "(選擇性)",
|
||||
"panel.optional_and_hidden": "(可選且隱藏)",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user