feat: support keyboard movement for workflow nodes and comments (#41967)

This commit is contained in:
Joel 2026-09-08 08:47:27 +00:00 committed by GitHub
parent 67f7a5517a
commit 93d6ff3889
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1123 additions and 17 deletions

View File

@ -0,0 +1,162 @@
import type { NodeProps } from 'reactflow'
import { useState } from 'react'
import ReactFlow, { Handle, Position, ReactFlowProvider, useStoreApi } from 'reactflow'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { WorkflowContext } from '../context'
import { useNodeKeyboardInteractions } from '../hooks/use-node-keyboard-interactions'
import { createWorkflowStore } from '../store/workflow'
import 'reactflow/dist/style.css'
import '../style.css'
vi.mock('../hooks/use-workflow', () => ({
useNodesReadOnly: () => ({ getNodesReadOnly: () => false }),
}))
vi.mock('../hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: vi.fn() }),
}))
vi.mock('../hooks/use-workflow-history', () => ({
WorkflowHistoryEvent: { NodeDragStop: 'NodeDragStop' },
useWorkflowHistory: () => ({ saveStateToHistory: vi.fn() }),
}))
vi.mock('../collaboration/core/collaboration-manager', () => ({
collaborationManager: {
setNodes: vi.fn(),
setEdges: vi.fn(),
canApplyLocalGraphMutation: () => true,
},
}))
const nodes = [
{
id: 'node',
type: 'test',
ariaLabel: 'Code',
position: { x: 100, y: 100 },
data: { title: 'Code' },
},
{
id: 'output',
type: 'test',
ariaLabel: 'Output',
position: { x: 500, y: 200 },
data: { title: 'Output' },
},
]
const edges = [{ id: 'connection', source: 'node', target: 'output', type: 'straight' }]
function TestNode({ id, data }: NodeProps) {
const store = useStoreApi()
return (
<div style={{ width: 200, height: 100 }}>
<Handle type="target" position={Position.Left} />
<button
type="button"
aria-label={`Select ${data.title}`}
data-node-keyboard-target
onClick={() => {
const { getNodes, setNodes } = store.getState()
setNodes(getNodes().map((node) => ({ ...node, selected: node.id === id })))
}}
>
{data.title}
</button>
<textarea className="nodrag" aria-label={`${data.title} editor`} />
<Handle type="source" position={Position.Right} />
</div>
)
}
const nodeTypes = { test: TestNode }
function Canvas() {
const [initialNodes] = useState(() =>
nodes.map((node) => ({
...node,
position: { ...node.position },
data: { ...node.data },
})),
)
const store = useStoreApi()
const onKeyDownCapture = useNodeKeyboardInteractions((id, cancel) => {
const { getNodes, setNodes } = store.getState()
setNodes(getNodes().map((node) => ({ ...node, selected: node.id === id && !cancel })))
})
return (
<div id="workflow-container" style={{ width: 800, height: 600 }}>
<ReactFlow
nodes={initialNodes}
edges={edges}
edgesFocusable={false}
nodeTypes={nodeTypes}
onKeyDownCapture={onKeyDownCapture}
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
/>
</div>
)
}
function Fixture() {
const [store] = useState(() => createWorkflowStore({}))
return (
<WorkflowContext value={store}>
<ReactFlowProvider>
<button type="button">Before canvas</button>
<Canvas />
</ReactFlowProvider>
</WorkflowContext>
)
}
it('moves a Tab-focused node without selecting it first, at canvas scale without consuming editor keys', async () => {
// Browser-owned: native tab order, focus visibility, and transformed canvas geometry.
await page.viewport(1000, 800)
const screen = await render(<Fixture />)
await screen.getByRole('button', { name: 'Before canvas' }).click()
await userEvent.tab()
const node = screen.getByRole('button', { name: 'Code', exact: true })
await expect.element(node).toHaveFocus()
expect(getComputedStyle(node.element()).outlineStyle).toBe('solid')
const edge = screen.getByRole('img', { name: 'Edge from node to output' })
await expect.element(edge).toBeVisible()
const initial = node.element().getBoundingClientRect()
const initialEdge = edge.element().getBoundingClientRect()
await userEvent.keyboard('{ArrowRight}{Shift>}{ArrowDown}{/Shift}')
await expect.poll(() => node.element().getBoundingClientRect().x).toBe(initial.x + 2.5)
expect(node.element().getBoundingClientRect().y).toBe(initial.y + 10)
expect(edge.element().getBoundingClientRect().x).toBe(initialEdge.x + 2.5)
expect(edge.element().getBoundingClientRect().y).toBe(initialEdge.y + 10)
await expect.element(node).toHaveFocus()
await userEvent.tab()
await userEvent.tab()
await expect.element(screen.getByRole('textbox', { name: 'Code editor' })).toHaveFocus()
await userEvent.keyboard('{ArrowRight}')
expect(node.element().getBoundingClientRect().x).toBe(initial.x + 2.5)
await userEvent.tab({ shift: true })
await userEvent.tab({ shift: true })
await userEvent.keyboard('{Escape}{ArrowRight}')
await expect.poll(() => node.element().getBoundingClientRect().x).toBe(initial.x + 5)
})
it('keeps a connected edge attached when a click-selected node moves with the keyboard', async () => {
// Browser-owned: React Flow measures real handles and projects their positions into the edge SVG.
await page.viewport(1000, 800)
const screen = await render(<Fixture />)
const node = screen.getByRole('button', { name: 'Code', exact: true })
const edge = screen.getByRole('img', { name: 'Edge from node to output' })
await expect.element(edge).toBeVisible()
const header = screen.getByRole('button', { name: 'Select Code' })
await header.click()
await expect.element(header).toHaveFocus()
const initialNode = node.element().getBoundingClientRect()
const initialEdge = edge.element().getBoundingClientRect()
await userEvent.keyboard('{ArrowRight}')
await expect.poll(() => node.element().getBoundingClientRect().x).toBe(initialNode.x + 2.5)
expect(edge.element().getBoundingClientRect().x).toBe(initialEdge.x + 2.5)
expect(edge.element().getBoundingClientRect().right).toBe(initialEdge.right)
await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}')
await expect.poll(() => node.element().getBoundingClientRect().x).toBe(initialNode.x - 7.5)
expect(edge.element().getBoundingClientRect().x).toBe(initialEdge.x - 7.5)
expect(edge.element().getBoundingClientRect().right).toBe(initialEdge.right)
await expect.element(header).toHaveFocus()
})

View File

@ -0,0 +1,136 @@
import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '../types'
import { act, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { renderWithAccountProfile } from '@/test/console/account-profile'
import { CommentIcon } from '../comment-icon'
import { CommentThread } from '../thread'
const storeState = vi.hoisted(() => ({
mentionableUsersCache: { 'app-1': [] } as Record<string, UserProfile[]>,
setCommentPreviewHovering: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ appId: 'app-1' }),
}))
vi.mock('reactflow', () => ({
useReactFlow: () => ({
flowToScreenPosition: (position: { x: number; y: number }) => position,
screenToFlowPosition: (position: { x: number; y: number }) => position,
}),
useViewport: () => ({ x: 0, y: 0, zoom: 1 }),
}))
vi.mock('../../store', () => ({
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
useWorkflowStore: () => ({ getState: () => storeState }),
}))
const createComment = (): WorkflowCommentDetail & WorkflowCommentList => ({
id: 'comment-1',
position_x: 120,
position_y: 80,
content: 'Move this comment',
created_by: 'user-1',
created_by_account: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
avatar_url: null,
},
created_at: 1,
updated_at: 2,
resolved: false,
mentions: [],
replies: [],
mention_count: 0,
reply_count: 0,
participants: [],
})
describe('Comment thread focus', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('keeps focus on the marker after saving a keyboard move with its thread open', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onPositionUpdate = vi.fn()
function Comment() {
const [comment, setComment] = useState(createComment)
const [opened, setOpened] = useState(false)
return (
<div id="workflow-container">
<CommentIcon
comment={comment}
isActive={opened}
onClick={() => setOpened(true)}
onPositionUpdate={(position) => {
onPositionUpdate(position)
setComment((current) => ({
...current,
position_x: position.x,
position_y: position.y,
}))
}}
/>
{opened && (
<CommentThread comment={comment} onClose={() => setOpened(false)} onReply={() => {}} />
)}
</div>
)
}
renderWithAccountProfile(<Comment />)
const marker = screen.getByRole('button', { name: /workflow.keyboard.openComment/ })
await user.tab()
await user.keyboard('{Enter}')
await act(() => vi.advanceTimersByTimeAsync(100))
expect(screen.getByRole('textbox')).toHaveFocus()
act(() => marker.focus())
await user.keyboard('{ArrowRight}')
expect(onPositionUpdate).toHaveBeenLastCalledWith({ x: 125, y: 80 })
await act(() => vi.advanceTimersByTimeAsync(100))
expect(marker).toHaveFocus()
await user.keyboard('{ArrowDown}')
expect(onPositionUpdate).toHaveBeenLastCalledWith({ x: 125, y: 85 })
expect(onPositionUpdate).toHaveBeenCalledTimes(2)
})
it('focuses the reply input when navigating to another comment', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onReply = vi.fn()
function Comments() {
const [comment, setComment] = useState(createComment)
return (
<CommentThread
comment={comment}
onClose={() => {}}
onReply={onReply}
canGoNext
onNext={() => setComment((current) => ({ ...current, id: 'comment-2' }))}
/>
)
}
renderWithAccountProfile(<Comments />)
await act(() => vi.advanceTimersByTimeAsync(100))
const replyInput = screen.getByRole('textbox')
expect(replyInput).toHaveFocus()
await user.click(screen.getByRole('button', { name: 'workflow.comments.aria.nextComment' }))
await act(() => vi.advanceTimersByTimeAsync(100))
expect(replyInput).toHaveFocus()
})
})

View File

@ -1,6 +1,8 @@
import type { ReactElement } from 'react'
import type { WorkflowCommentList } from '@/app/components/workflow/comment/types'
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createAccountProfileQueryWrapper } from '@/test/console/account-profile'
import { render as renderWithConsoleState } from '@/test/console/render'
@ -81,6 +83,61 @@ describe('CommentIcon', () => {
mockUserId = 'user-1'
})
it('opens with the keyboard and moves an authored comment in canvas coordinates', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
const onPositionUpdate = vi.fn()
function Comment() {
const [comment, setComment] = useState(() => createComment())
return (
<CommentIcon
comment={comment}
onClick={onClick}
onPositionUpdate={(position) => {
onPositionUpdate(position)
setComment((current) => ({
...current,
position_x: position.x,
position_y: position.y,
}))
}}
/>
)
}
render(<Comment />)
await user.tab()
const marker = screen.getByRole('button', { name: /workflow.keyboard.openComment/ })
expect(marker).toHaveFocus()
await user.keyboard('{Enter}')
expect(onClick).toHaveBeenCalledTimes(1)
await user.keyboard('{ArrowRight}{Shift>}{ArrowDown}{/Shift}')
expect(onPositionUpdate).toHaveBeenLastCalledWith({ x: 5, y: 20 })
expect(marker).toHaveFocus()
onPositionUpdate.mockClear()
await user.keyboard('{ArrowRight>3}')
expect(onPositionUpdate).not.toHaveBeenCalled()
await user.keyboard('{/ArrowRight}')
expect(onPositionUpdate).toHaveBeenCalledExactlyOnceWith({ x: 20, y: 20 })
})
it("allows opening another author's comment but does not move it with arrow keys", async () => {
const user = userEvent.setup()
mockUserId = 'user-2'
const onClick = vi.fn()
const onPositionUpdate = vi.fn()
render(
<CommentIcon
comment={createComment()}
onClick={onClick}
onPositionUpdate={onPositionUpdate}
/>,
)
await user.tab()
await user.keyboard('{ArrowRight}{Enter}')
expect(onPositionUpdate).not.toHaveBeenCalled()
expect(onClick).toHaveBeenCalledTimes(1)
})
it('toggles preview on hover when inactive', () => {
const comment = createComment()
const { container } = render(

View File

@ -2,11 +2,14 @@
import type { FC, PointerEvent as ReactPointerEvent } from 'react'
import type { WorkflowCommentList } from '@/app/components/workflow/comment/types'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useSuspenseQuery } from '@tanstack/react-query'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useReactFlow, useViewport } from 'reactflow'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { getKeyboardMovement } from '../utils/keyboard-movement'
import CommentPreview from './comment-preview'
type CommentIconProps = {
@ -18,6 +21,8 @@ type CommentIconProps = {
export const CommentIcon: FC<CommentIconProps> = memo(
({ comment, onClick, isActive = false, onPositionUpdate }) => {
const { t } = useTranslation('workflow')
const descriptionId = useId()
const { flowToScreenPosition, screenToFlowPosition } = useReactFlow()
const viewport = useViewport()
const { data: currentUserId } = useSuspenseQuery({
@ -28,6 +33,14 @@ export const CommentIcon: FC<CommentIconProps> = memo(
const [showPreview, setShowPreview] = useState(false)
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null)
const [isDragging, setIsDragging] = useState(false)
const keyboardPositionRef = useRef<{ x: number; y: number } | null>(null)
const finishKeyboardMove = useCallback(() => {
const position = keyboardPositionRef.current
if (!position) return
keyboardPositionRef.current = null
setDragPosition(null)
onPositionUpdate?.(position)
}, [onPositionUpdate])
const dragStateRef = useRef<{
offsetX: number
offsetY: number
@ -72,7 +85,7 @@ export const CommentIcon: FC<CommentIconProps> = memo(
}, [isActive, isAuthor, isDragging])
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
(event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0) return
event.stopPropagation()
@ -102,7 +115,7 @@ export const CommentIcon: FC<CommentIconProps> = memo(
[isAuthor, screenPosition],
)
const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
const dragState = dragStateRef.current
if (!dragState) return
@ -126,7 +139,7 @@ export const CommentIcon: FC<CommentIconProps> = memo(
setDragPosition({ x: nextX, y: nextY })
}, [])
const finishDrag = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const finishDrag = useCallback((event: ReactPointerEvent<HTMLElement>) => {
const dragState = dragStateRef.current
if (!dragState) return false
@ -140,7 +153,7 @@ export const CommentIcon: FC<CommentIconProps> = memo(
}, [])
const handlePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
(event: ReactPointerEvent<HTMLElement>) => {
event.stopPropagation()
event.preventDefault()
@ -173,7 +186,7 @@ export const CommentIcon: FC<CommentIconProps> = memo(
)
const handlePointerCancel = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
(event: ReactPointerEvent<HTMLElement>) => {
event.stopPropagation()
event.preventDefault()
finishDrag(event)
@ -226,8 +239,12 @@ export const CommentIcon: FC<CommentIconProps> = memo(
return (
<>
<div
className="absolute z-10"
<IconButton
aria-label={t(($) => $['keyboard.openComment'], {
name: comment.created_by_account?.name ?? '',
})}
aria-describedby={isAuthor && onPositionUpdate ? descriptionId : undefined}
className="absolute z-10 h-auto w-auto border-0 bg-transparent p-0"
style={{
left: canvasPosition.x,
top: canvasPosition.y,
@ -235,6 +252,30 @@ export const CommentIcon: FC<CommentIconProps> = memo(
}}
data-role="comment-marker"
{...pointerEventHandlers}
onClick={(event) => {
if (event.detail === 0 && !isActive) {
finishKeyboardMove()
onClick()
}
}}
onBlur={finishKeyboardMove}
onKeyUp={(event) => {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key))
finishKeyboardMove()
}}
onKeyDown={(event) => {
const delta = getKeyboardMovement(event)
if (!delta || !isAuthor || !onPositionUpdate) return
event.preventDefault()
event.stopPropagation()
const current = keyboardPositionRef.current ?? {
x: comment.position_x,
y: comment.position_y,
}
const next = { x: current.x + delta.x, y: current.y + delta.y }
keyboardPositionRef.current = next
setDragPosition(flowToScreenPosition(next))
}}
>
<div
className={cursorClass}
@ -263,7 +304,13 @@ export const CommentIcon: FC<CommentIconProps> = memo(
</div>
</div>
</div>
</div>
</IconButton>
{isAuthor && onPositionUpdate && (
<span id={descriptionId} className="sr-only" aria-live="polite">
{t(($) => $['keyboard.moveHelp'])}{' '}
{t(($) => $['keyboard.position'], { x: comment.position_x, y: comment.position_y })}
</span>
)}
{/* Preview panel */}
{showPreview && !isActive && (

View File

@ -1,5 +1,7 @@
import type { FC, ReactElement } from 'react'
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createAccountProfileQueryWrapper } from '@/test/console/account-profile'
import { render as renderWithConsoleState } from '@/test/console/render'
@ -62,6 +64,53 @@ describe('CommentInput', () => {
mentionInputProps = null
})
it('moves the draft using a focused handle and finishes moving with Enter', async () => {
const user = userEvent.setup()
const onPositionChange = vi.fn()
function Draft() {
const [position, setPosition] = useState({ x: 100, y: 100 })
return (
<CommentInput
position={position}
onCancel={vi.fn()}
onSubmit={vi.fn()}
onPositionChange={(next) => {
onPositionChange(next)
setPosition({ x: next.elementX, y: next.elementY })
}}
/>
)
}
render(<Draft />)
await user.tab()
const handle = screen.getByRole('button', { name: 'workflow.keyboard.moveDraftComment' })
await user.keyboard('{Enter}{ArrowRight}{Shift>}{ArrowDown}{/Shift}')
expect(onPositionChange).toHaveBeenLastCalledWith(
expect.objectContaining({ elementX: 105, elementY: 120 }),
)
expect(handle).toHaveAttribute('aria-pressed', 'true')
await user.keyboard('{Enter}{ArrowRight}')
expect(handle).toHaveAttribute('aria-pressed', 'false')
expect(onPositionChange).toHaveBeenCalledTimes(2)
await user.tab()
expect(screen.getByTestId('mention-input')).toHaveFocus()
})
it('does not offer keyboard movement while disabled', () => {
render(
<CommentInput
position={{ x: 0, y: 0 }}
onCancel={vi.fn()}
onSubmit={vi.fn()}
onPositionChange={vi.fn()}
disabled
/>,
)
expect(
screen.getByRole('button', { name: 'workflow.keyboard.moveDraftComment' }),
).toBeDisabled()
})
it('passes translated placeholder to mention input', () => {
render(<CommentInput position={{ x: 0, y: 0 }} onSubmit={vi.fn()} onCancel={vi.fn()} />)

View File

@ -1,10 +1,12 @@
import type { FC, PointerEvent as ReactPointerEvent } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useSuspenseQuery } from '@tanstack/react-query'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { getKeyboardMovement } from '../utils/keyboard-movement'
import { MentionInput } from './mention-input'
type CommentInputProps = {
@ -24,6 +26,8 @@ type CommentInputProps = {
export const CommentInput: FC<CommentInputProps> = memo(
({ position, onSubmit, onCancel, autoFocus = true, disabled = false, onPositionChange }) => {
const [content, setContent] = useState('')
const [keyboardMoving, setKeyboardMoving] = useState(false)
const moveDescriptionId = useId()
const { t } = useTranslation()
const { data: userProfile } = useSuspenseQuery({
...userProfileQueryOptions(),
@ -110,7 +114,7 @@ export const CommentInput: FC<CommentInputProps> = memo(
)
const handleDragPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
(event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0) return
event.stopPropagation()
event.preventDefault()
@ -151,7 +155,33 @@ export const CommentInput: FC<CommentInputProps> = memo(
data-comment-input
>
<div className="flex items-center gap-3">
<div className="relative shrink-0 cursor-move" onPointerDown={handleDragPointerDown}>
<IconButton
aria-label={t(($) => $['keyboard.moveDraftComment'], { ns: 'workflow' })}
aria-describedby={moveDescriptionId}
aria-pressed={keyboardMoving}
disabled={disabled || !onPositionChange}
className="relative size-8 shrink-0 cursor-move p-0"
onPointerDown={handleDragPointerDown}
onClick={(event) => {
if (event.detail === 0) setKeyboardMoving((value) => !value)
}}
onBlur={() => setKeyboardMoving(false)}
onKeyDown={(event) => {
const delta = getKeyboardMovement(event)
if (!delta || !keyboardMoving || disabled || !onPositionChange) return
event.preventDefault()
event.stopPropagation()
const rect = event.currentTarget
.closest('[data-comment-input]')
?.getBoundingClientRect()
onPositionChange({
pageX: (rect?.left ?? position.x) + delta.x,
pageY: (rect?.top ?? position.y) + delta.y,
elementX: position.x + delta.x,
elementY: position.y + delta.y,
})
}}
>
<div className="relative aspect-square h-8 w-8 shrink-0 rounded-tl-full rounded-tr-full rounded-br-full bg-primary-500 p-0.5">
<div className="flex size-full items-center justify-center overflow-hidden rounded-tl-full rounded-tr-full rounded-br-full bg-components-panel-bg-blur p-0.5">
<Avatar
@ -162,7 +192,11 @@ export const CommentInput: FC<CommentInputProps> = memo(
/>
</div>
</div>
</div>
</IconButton>
<span id={moveDescriptionId} className="sr-only" aria-live="polite">
{t(($) => $['keyboard.moveDraftHelp'], { ns: 'workflow' })}{' '}
{t(($) => $['keyboard.position'], { ns: 'workflow', x: position.x, y: position.y })}
</span>
<div
className={cn(
'relative z-10 flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-1 shadow-md',

View File

@ -0,0 +1,118 @@
import type { WorkflowCommentList } from './types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Suspense, useState } from 'react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { CommentIcon } from './comment-icon'
import { CommentInput } from './comment-input'
import 'reactflow/dist/style.css'
vi.mock('@/features/account-profile/client', () => ({
userProfileQueryOptions: () => ({
queryKey: ['profile'],
queryFn: async () => ({ profile: { id: 'author', name: 'Alice', avatar_url: null } }),
}),
}))
vi.mock('./comment-preview', () => ({ default: () => null }))
vi.mock('./mention-input', () => ({ MentionInput: () => <textarea aria-label="Comment text" /> }))
const comment: WorkflowCommentList = {
id: 'comment',
content: 'Comment',
created_by: 'author',
position_x: 100,
position_y: 100,
created_by_account: { id: 'author', name: 'Alice', email: 'alice@example.com', avatar_url: null },
created_at: 1,
updated_at: 1,
resolved: false,
mention_count: 0,
reply_count: 0,
participants: [],
}
function Fixture({ draft = false }: { draft?: boolean }) {
const [queryClient] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
)
const [current, setCurrent] = useState(comment)
const [position, setPosition] = useState({ x: 100, y: 100 })
const [opened, setOpened] = useState(false)
return (
<QueryClientProvider client={queryClient}>
<Suspense fallback={<p>Loading profile</p>}>
<ReactFlowProvider>
<button type="button">Before canvas</button>
<div
id="workflow-container"
role="group"
aria-label="Canvas"
style={{ position: 'relative', width: 800, height: 600 }}
>
{draft ? (
<CommentInput
position={position}
onSubmit={() => {}}
onCancel={() => {}}
onPositionChange={(next) => setPosition({ x: next.elementX, y: next.elementY })}
/>
) : (
<CommentIcon
comment={current}
onClick={() => setOpened(true)}
onPositionUpdate={(next) =>
setCurrent((value) => ({ ...value, position_x: next.x, position_y: next.y }))
}
/>
)}
<ReactFlow nodes={[]} />
{opened && <p role="status">Comment opened</p>}
</div>
</ReactFlowProvider>
</Suspense>
</QueryClientProvider>
)
}
it('keeps an authored comment focusable and draggable after adding keyboard movement', async () => {
// Browser-owned: real pointer capture, marker hit testing, and native button activation.
await page.viewport(1000, 800)
const screen = await render(<Fixture />)
const marker = screen.getByRole('button', { name: /workflow.keyboard.openComment/ })
await expect.element(marker).toBeVisible()
await screen.getByRole('button', { name: 'Before canvas' }).click()
await userEvent.tab()
await expect.element(marker).toHaveFocus()
const initial = marker.element().getBoundingClientRect()
await userEvent.keyboard('{ArrowRight}')
await expect.poll(() => marker.element().getBoundingClientRect().x).toBe(initial.x + 5)
await userEvent.dragAndDrop(marker, screen.getByRole('group', { name: 'Canvas' }), {
targetPosition: { x: 300, y: 250 },
})
expect(marker.element().getBoundingClientRect().x).toBeGreaterThan(initial.x + 5)
await expect.element(screen.getByRole('status')).not.toBeInTheDocument()
await marker.click()
await expect.element(screen.getByRole('status')).toHaveTextContent('Comment opened')
})
it('preserves draft mouse dragging and lets keyboard users move the same handle', async () => {
// Browser-owned: native document pointer events and geometry of the positioned draft.
await page.viewport(1000, 800)
const screen = await render(<Fixture draft />)
const handle = screen.getByRole('button', { name: 'workflow.keyboard.moveDraftComment' })
await expect.element(handle).toBeVisible()
const initial = handle.element().getBoundingClientRect()
await userEvent.dragAndDrop(handle, screen.getByRole('group', { name: 'Canvas' }), {
targetPosition: { x: 300, y: 250 },
})
expect(handle.element().getBoundingClientRect().x).toBeGreaterThan(initial.x)
await screen.getByRole('button', { name: 'Before canvas' }).click()
await userEvent.tab()
await expect.element(handle).toHaveFocus()
const dragged = handle.element().getBoundingClientRect()
await userEvent.keyboard('{Enter}{ArrowRight}{Shift>}{ArrowDown}{/Shift}{Enter}')
await expect.poll(() => handle.element().getBoundingClientRect().x).toBe(dragged.x + 5)
expect(handle.element().getBoundingClientRect().y).toBe(dragged.y + 20)
await expect.element(handle).toHaveAttribute('aria-pressed', 'false')
})

View File

@ -236,15 +236,17 @@ export const CommentThread: FC<CommentThreadProps> = memo(
[setCommentPreviewHovering],
)
// P0: Auto-focus reply input when thread opens or comment changes
const canReply = Boolean(onReply)
// Focus on thread transitions, not callback changes after position updates.
useEffect(() => {
const timer = setTimeout(() => {
if (replyInputRef.current && !editingReply.id && !isCommentEditing && onReply)
if (replyInputRef.current && !editingReply.id && !isCommentEditing && canReply)
replyInputRef.current.focus()
}, 100)
return () => clearTimeout(timer)
}, [comment.id, editingReply.id, isCommentEditing, onReply])
}, [comment.id, editingReply.id, isCommentEditing, canReply])
// P2: Handle Esc key to close thread
useEffect(() => {

View File

@ -0,0 +1,201 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createNode } from '../../__tests__/fixtures'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { collaborationManager } from '../../collaboration/core/collaboration-manager'
import { CUSTOM_ITERATION_START_NODE } from '../../nodes/iteration-start/constants'
import { BlockEnum, ControlMode } from '../../types'
import { useNodeKeyboardInteractions } from '../use-node-keyboard-interactions'
const state = vi.hoisted(() => ({
readonly: false,
sync: vi.fn(),
history: vi.fn(),
select: vi.fn(),
}))
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(),
)
vi.mock('../use-workflow', () => ({
useNodesReadOnly: () => ({ getNodesReadOnly: () => state.readonly }),
}))
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: state.sync }),
}))
vi.mock('../use-workflow-history', () => ({
WorkflowHistoryEvent: { NodeDragStop: 'NodeDragStop' },
useWorkflowHistory: () => ({ saveStateToHistory: state.history }),
}))
function Canvas() {
const onKeyDownCapture = useNodeKeyboardInteractions(state.select)
return (
<div onKeyDownCapture={onKeyDownCapture}>
<div role="button" tabIndex={0} className="react-flow__node" data-id="node">
Node
<button type="button" data-node-keyboard-target onClick={() => state.select('node')}>
Select node
</button>
</div>
<div
role="button"
tabIndex={0}
className="react-flow__nodesselection-rect"
aria-label="Selection"
/>
<input aria-label="Node title" />
</div>
)
}
describe('node keyboard interactions', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
vi.spyOn(collaborationManager, 'canApplyLocalGraphMutation').mockReturnValue(true)
vi.spyOn(collaborationManager, 'setNodes').mockImplementation(() => {})
state.readonly = false
rfState.nodes = [
createNode({ id: 'node', selected: true, position: { x: 0, y: 0 }, width: 100, height: 80 }),
]
rfState.setNodes.mockImplementation((nodes) => {
rfState.nodes = nodes
})
})
it('moves from the origin, broadcasts the old and new positions, and saves undo history', async () => {
const user = userEvent.setup()
renderWorkflowComponent(<Canvas />)
await user.tab()
await user.keyboard('{ArrowRight}{Shift>}{ArrowDown}{/Shift}')
expect(rfState.nodes[0]!.position).toEqual({ x: 5, y: 20 })
expect(collaborationManager.setNodes).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ position: { x: 5, y: 0 } })]),
expect.arrayContaining([expect.objectContaining({ position: { x: 5, y: 20 } })]),
'keyboard-node-movement',
)
expect(state.sync).toHaveBeenCalledTimes(2)
expect(state.history).toHaveBeenLastCalledWith('NodeDragStop', { nodeId: 'node' })
})
it('selects and deselects the focused node while leaving editor keys alone', async () => {
const user = userEvent.setup()
renderWorkflowComponent(<Canvas />)
await user.tab()
await user.keyboard('{Enter}{Escape}')
expect(state.select.mock.calls).toEqual([
['node', false],
['node', true],
])
await user.click(screen.getByRole('textbox', { name: 'Node title' }))
await user.keyboard('{ArrowDown}')
expect(state.sync).not.toHaveBeenCalled()
})
it('moves a Tab-focused unselected node without moving a separate selection', async () => {
rfState.nodes = [
createNode({ id: 'node', selected: false, position: { x: 0, y: 0 } }),
createNode({ id: 'other', selected: true, position: { x: 100, y: 100 } }),
]
const user = userEvent.setup()
renderWorkflowComponent(<Canvas />)
await user.tab()
await user.keyboard('{ArrowRight}')
expect(rfState.nodes.map((node) => node.position)).toEqual([
{ x: 5, y: 0 },
{ x: 100, y: 100 },
])
expect(state.sync).toHaveBeenCalledOnce()
expect(state.history).toHaveBeenCalledWith('NodeDragStop', { nodeId: 'node' })
})
it('moves from the title button through collaboration and keeps its native activation', async () => {
const user = userEvent.setup()
renderWorkflowComponent(<Canvas />)
await user.click(screen.getByRole('button', { name: 'Select node' }))
await user.keyboard('{ArrowRight}')
expect(rfState.nodes[0]!.position).toEqual({ x: 5, y: 0 })
expect(collaborationManager.setNodes).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ position: { x: 0, y: 0 } })]),
expect.arrayContaining([expect.objectContaining({ position: { x: 5, y: 0 } })]),
'keyboard-node-movement',
)
expect(state.sync).toHaveBeenCalledOnce()
state.select.mockClear()
await user.keyboard('{Enter} ')
expect(state.select.mock.calls).toEqual([['node'], ['node']])
})
it('keeps selected children stationary relative to a moving selected container', async () => {
rfState.nodes = [
createNode({
id: 'node',
selected: true,
data: { type: BlockEnum.Iteration },
position: { x: 100, y: 100 },
}),
createNode({ id: 'child', selected: true, parentId: 'node', position: { x: 30, y: 60 } }),
createNode({ id: 'other', selected: true, position: { x: 500, y: 500 } }),
]
const user = userEvent.setup()
renderWorkflowComponent(<Canvas />)
await user.tab()
await user.tab()
await user.tab()
await user.keyboard('{ArrowRight}')
expect(rfState.nodes.map((node) => node.position)).toEqual([
{ x: 105, y: 100 },
{ x: 30, y: 60 },
{ x: 505, y: 500 },
])
})
it.each(['iteration', 'loop'] as const)(
'clamps a child to its %s container and skips empty history at the boundary',
async (type) => {
rfState.nodes = [
createNode({
id: 'parent',
width: 300,
height: 250,
data: { type: type === 'iteration' ? BlockEnum.Iteration : BlockEnum.Loop },
}),
createNode({
id: 'node',
parentId: 'parent',
selected: true,
width: 100,
height: 80,
position: { x: 184, y: 150 },
data: { isInIteration: type === 'iteration', isInLoop: type === 'loop' },
}),
]
const user = userEvent.setup()
renderWorkflowComponent(<Canvas />)
await user.tab()
await user.keyboard('{ArrowRight}{ArrowDown}')
expect(rfState.nodes[1]!.position).toEqual({ x: 184, y: 150 })
expect(state.sync).not.toHaveBeenCalled()
},
)
it.each(['readonly', 'comment', 'locked', 'start'] as const)(
'does not move in %s state',
async (mode) => {
state.readonly = mode === 'readonly'
if (mode === 'locked') Object.assign(rfState.nodes[0]!, { draggable: false })
if (mode === 'start') Object.assign(rfState.nodes[0]!, { type: CUSTOM_ITERATION_START_NODE })
const user = userEvent.setup()
renderWorkflowComponent(<Canvas />, {
initialStoreState: {
controlMode: mode === 'comment' ? ControlMode.Comment : ControlMode.Pointer,
},
})
await user.tab()
await user.keyboard('{ArrowRight}')
expect(state.sync).not.toHaveBeenCalled()
expect(rfState.nodes[0]!.position).toEqual({ x: 0, y: 0 })
},
)
})

View File

@ -0,0 +1,123 @@
import type { KeyboardEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
import { getRestrictedIterationPosition } from '../nodes/iteration/use-interactions.helpers'
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
import { getRestrictedLoopPosition } from '../nodes/loop/use-interactions.helpers'
import { useWorkflowStore } from '../store'
import { BlockEnum, ControlMode } from '../types'
import { getKeyboardMovement } from '../utils/keyboard-movement'
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useNodesReadOnly } from './use-workflow'
import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history'
export function useNodeKeyboardInteractions(onSelect: (id: string, cancel?: boolean) => void) {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const workflow = useCollaborativeWorkflow()
const { getNodesReadOnly } = useNodesReadOnly()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { saveStateToHistory } = useWorkflowHistory()
const { t } = useTranslation('workflow')
return (event: KeyboardEvent<HTMLDivElement>) => {
const target = event.target
if (!(target instanceof HTMLElement)) return
const isNodeTitle = target.hasAttribute('data-node-keyboard-target')
const nodeTarget = isNodeTitle ? target.closest<HTMLElement>('.react-flow__node') : target
const isNode = nodeTarget?.classList.contains('react-flow__node')
const isSelection = target.classList.contains('react-flow__nodesselection-rect')
if (!isNode && !isSelection) return
// The title button owns its normal click activation.
if (isNodeTitle && (event.key === 'Enter' || event.key === ' ')) return
const movement = getKeyboardMovement(event)
const isMovementKey = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)
const isSelectionKey = ['Enter', ' ', 'Escape'].includes(event.key)
if (!isMovementKey && !isSelectionKey) return
// React Flow 11 mutates its internal nodes before onNodesChange. Handle these
// keys before that mutation so collaboration receives the old and new positions.
event.preventDefault()
event.stopPropagation()
if (event.altKey || event.ctrlKey || event.metaKey) return
if (getNodesReadOnly() || workflowStore.getState().controlMode === ControlMode.Comment) return
const { nodes, setNodes } = workflow.getState()
const focusedNode = isNode
? nodes.find((node) => node.id === nodeTarget?.dataset.id)
: undefined
if (isNode && !focusedNode) return
if (
focusedNode &&
(focusedNode.type === CUSTOM_ITERATION_START_NODE ||
focusedNode.type === CUSTOM_LOOP_START_NODE ||
focusedNode.data.type === BlockEnum.DataSourceEmpty)
)
return
if (isSelectionKey) {
if (focusedNode) onSelect(focusedNode.id, event.key === 'Escape')
return
}
if (!movement) return
if (!collaborationManager.canApplyLocalGraphMutation()) return
const movingIds = new Set(
nodes
.filter(
(node) =>
(focusedNode && !focusedNode.selected ? node.id === focusedNode.id : node.selected) &&
node.draggable !== false &&
node.type !== CUSTOM_ITERATION_START_NODE &&
node.type !== CUSTOM_LOOP_START_NODE &&
node.data.type !== BlockEnum.DataSourceEmpty,
)
.map((node) => node.id),
)
let moved = false
const nextNodes = nodes.map((node) => {
if (!movingIds.has(node.id)) return node
// A selected container already moves its descendants in canvas coordinates.
let parent = nodes.find((candidate) => candidate.id === node.parentId)
while (parent) {
if (movingIds.has(parent.id)) return node
parent = nodes.find((candidate) => candidate.id === parent?.parentId)
}
const next = {
...node,
position: { x: node.position.x + movement.x, y: node.position.y + movement.y },
}
const parentNode = nodes.find((candidate) => candidate.id === node.parentId)
const iteration = getRestrictedIterationPosition(next, parentNode)
const loop = getRestrictedLoopPosition(next, parentNode)
next.position = {
x: iteration.x ?? loop.x ?? next.position.x,
y: iteration.y ?? loop.y ?? next.position.y,
}
if (next.position.x === node.position.x && next.position.y === node.position.y) return node
moved = true
return next
})
if (!moved) return
workflowStore.setState({ nodeAnimation: false })
setNodes(nextNodes, true, 'keyboard-node-movement')
handleSyncWorkflowDraft()
saveStateToHistory(
WorkflowHistoryEvent.NodeDragStop,
focusedNode ? { nodeId: focusedNode.id } : undefined,
)
const announcedNode =
nextNodes.find((node) => node.id === focusedNode?.id) ??
nextNodes.find((node) => movingIds.has(node.id))
if (announcedNode)
store.setState({
ariaLiveMessage: t(($) => $['keyboard.nodeMoved'], {
title: announcedNode.data.title,
x: Math.round(announcedNode.position.x),
y: Math.round(announcedNode.position.y),
}),
})
}
}

View File

@ -72,6 +72,7 @@ import HelpLine from './help-line'
import { HooksStoreContextProvider, useHooksStore } from './hooks-store'
import { useEdgesInteractions } from './hooks/use-edges-interactions'
import { useLocateNode } from './hooks/use-locate-node'
import { useNodeKeyboardInteractions } from './hooks/use-node-keyboard-interactions'
import { useNodesInteractions } from './hooks/use-nodes-interactions'
import { useNodesSyncDraft } from './hooks/use-nodes-sync-draft'
import { usePanelInteractions } from './hooks/use-panel-interactions'
@ -559,6 +560,7 @@ export const Workflow: FC<WorkflowProps> = memo(
handleNodeEnter,
handleNodeLeave,
handleNodeClick,
handleNodeSelect,
handleNodeConnect,
handleNodeConnectStart,
handleNodeConnectEnd,
@ -566,6 +568,7 @@ export const Workflow: FC<WorkflowProps> = memo(
handleHistoryBack,
handleHistoryForward,
} = useNodesInteractions()
const handleNodeKeyDown = useNodeKeyboardInteractions(handleNodeSelect)
const { handleEdgeEnter, handleEdgeLeave, handleEdgesChange, handleEdgeContextMenu } =
useEdgesInteractions()
const {
@ -767,6 +770,7 @@ export const Workflow: FC<WorkflowProps> = memo(
edgeTypes={edgeTypes}
nodes={nodes}
edges={edges}
onKeyDownCapture={handleNodeKeyDown}
className={controlMode === ControlMode.Comment ? 'comment-mode-flow' : ''}
onNodeDragStart={handleNodeDragStart}
onNodeDrag={handleNodeDrag}

View File

@ -244,6 +244,7 @@ const BaseNode: FC<BaseNodeProps> = ({ id, data, children }) => {
>
<button
type="button"
data-node-keyboard-target
aria-label={data.title}
className="mr-1 flex min-w-0 grow appearance-none items-center rounded-md border-0 bg-transparent p-0 text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => {

View File

@ -6,6 +6,12 @@
transition: transform 0.2s ease-in-out;
}
#workflow-container .react-flow__node:focus-visible,
#workflow-container .react-flow__nodesselection-rect:focus-visible {
outline: 2px solid var(--color-state-accent-solid);
outline-offset: 4px;
}
/* Comment mode cursor override */
.comment-mode-flow .react-flow__pane,
.comment-mode-flow .react-flow__viewport {

View File

@ -0,0 +1,18 @@
import type { KeyboardEvent } from 'react'
export function getKeyboardMovement(
event: Pick<KeyboardEvent, 'key' | 'shiftKey' | 'altKey' | 'ctrlKey' | 'metaKey'>,
) {
if (event.altKey || event.ctrlKey || event.metaKey) return
const step = event.shiftKey ? 20 : 5
switch (event.key) {
case 'ArrowLeft':
return { x: -step, y: 0 }
case 'ArrowRight':
return { x: step, y: 0 }
case 'ArrowUp':
return { x: 0, y: -step }
case 'ArrowDown':
return { x: 0, y: step }
}
}

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "معرف سير العمل",
"globalVar.fieldsDescription.workflowRunId": "معرف تشغيل سير العمل",
"globalVar.title": "متغيرات النظام",
"keyboard.moveDraftComment": "تحريك مسودة التعليق",
"keyboard.moveDraftHelp": "اضغط Enter أو مفتاح المسافة لبدء التحريك أو إنهائه. استخدم الأسهم واضغط مع الاستمرار على Shift لخطوات أكبر.",
"keyboard.moveHelp": "استخدم مفاتيح الأسهم للتحريك. اضغط مع الاستمرار على Shift لخطوات أكبر.",
"keyboard.nodeMoved": "تم نقل {{title}} إلى x {{x}}، y {{y}}.",
"keyboard.openComment": "فتح تعليق {{name}}",
"keyboard.position": "الموضع: x {{x}}، y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "الاستراتيجية غير محددة",
"nodes.agent.clickToViewParameterSchema": "انقر لعرض مخطط المعلمة",
"nodes.agent.installPlugin.cancel": "إلغاء",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "Workflow-ID",
"globalVar.fieldsDescription.workflowRunId": "Workflow-Ausführungs-ID",
"globalVar.title": "Systemvariablen",
"keyboard.moveDraftComment": "Kommentarentwurf verschieben",
"keyboard.moveDraftHelp": "Mit Eingabe oder Leertaste das Verschieben beginnen oder beenden. Pfeiltasten zum Verschieben, Umschalttaste für größere Schritte.",
"keyboard.moveHelp": "Mit den Pfeiltasten verschieben. Umschalttaste für größere Schritte gedrückt halten.",
"keyboard.nodeMoved": "{{title}} nach x {{x}}, y {{y}} verschoben.",
"keyboard.openComment": "Kommentar von {{name}} öffnen",
"keyboard.position": "Position: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Strategie nicht ausgewählt",
"nodes.agent.clickToViewParameterSchema": "Klicken Sie hier, um das Parameterschema anzuzeigen.",
"nodes.agent.installPlugin.cancel": "Abbrechen",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "Workflow ID",
"globalVar.fieldsDescription.workflowRunId": "Workflow run ID",
"globalVar.title": "System Variables",
"keyboard.moveDraftComment": "Move draft comment",
"keyboard.moveDraftHelp": "Press Enter or Space to start or finish moving. Use arrow keys to move; hold Shift for larger steps.",
"keyboard.moveHelp": "Use arrow keys to move. Hold Shift for larger steps.",
"keyboard.nodeMoved": "Moved {{title}} to x {{x}}, y {{y}}.",
"keyboard.openComment": "Open comment by {{name}}",
"keyboard.position": "Position: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Strategy not selected",
"nodes.agent.clickToViewParameterSchema": "Click to view parameter schema",
"nodes.agent.installPlugin.cancel": "Cancel",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "ID del flujo de trabajo",
"globalVar.fieldsDescription.workflowRunId": "ID de ejecución del flujo de trabajo",
"globalVar.title": "Variables del sistema",
"keyboard.moveDraftComment": "Mover borrador del comentario",
"keyboard.moveDraftHelp": "Pulsa Intro o Espacio para iniciar o terminar el movimiento. Usa las flechas y mantén Mayús para pasos más grandes.",
"keyboard.moveHelp": "Usa las flechas para mover. Mantén Mayús para pasos más grandes.",
"keyboard.nodeMoved": "{{title}} se ha movido a x {{x}}, y {{y}}.",
"keyboard.openComment": "Abrir comentario de {{name}}",
"keyboard.position": "Posición: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Estrategia no seleccionada",
"nodes.agent.clickToViewParameterSchema": "Haga clic para ver el esquema de parámetros",
"nodes.agent.installPlugin.cancel": "Cancelar",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "شناسه گردش کار",
"globalVar.fieldsDescription.workflowRunId": "شناسه اجرای گردش کار",
"globalVar.title": "متغیرهای سیستمی",
"keyboard.moveDraftComment": "جابه‌جایی پیش‌نویس نظر",
"keyboard.moveDraftHelp": "برای شروع یا پایان جابه‌جایی Enter یا فاصله را فشار دهید. از کلیدهای جهت استفاده کنید و برای گام‌های بزرگ‌تر Shift را نگه دارید.",
"keyboard.moveHelp": "برای جابه‌جایی از کلیدهای جهت استفاده کنید. برای گام‌های بزرگ‌تر Shift را نگه دارید.",
"keyboard.nodeMoved": "{{title}} به x {{x}}، y {{y}} منتقل شد.",
"keyboard.openComment": "باز کردن نظر {{name}}",
"keyboard.position": "موقعیت: x {{x}}، y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "استراتژی انتخاب نشده است",
"nodes.agent.clickToViewParameterSchema": "برای مشاهده طرح پارامتر کلیک کنید",
"nodes.agent.installPlugin.cancel": "لغو",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "ID du workflow",
"globalVar.fieldsDescription.workflowRunId": "ID d'exécution du workflow",
"globalVar.title": "Variables système",
"keyboard.moveDraftComment": "Déplacer le brouillon du commentaire",
"keyboard.moveDraftHelp": "Appuyez sur Entrée ou Espace pour commencer ou terminer le déplacement. Utilisez les flèches et maintenez Maj pour des déplacements plus grands.",
"keyboard.moveHelp": "Utilisez les flèches pour déplacer. Maintenez Maj pour des déplacements plus grands.",
"keyboard.nodeMoved": "{{title}} déplacé en x {{x}}, y {{y}}.",
"keyboard.openComment": "Ouvrir le commentaire de {{name}}",
"keyboard.position": "Position : x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Stratégie non sélectionnée",
"nodes.agent.clickToViewParameterSchema": "Cliquez pour voir le schéma des paramètres",
"nodes.agent.installPlugin.cancel": "Annuler",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "वर्कफ़्लो ID",
"globalVar.fieldsDescription.workflowRunId": "वर्कफ़्लो रन ID",
"globalVar.title": "सिस्टम वेरिएबल्स",
"keyboard.moveDraftComment": "टिप्पणी का मसौदा खिसकाएँ",
"keyboard.moveDraftHelp": "स्थान बदलना शुरू या समाप्त करने के लिए Enter या स्पेस दबाएँ। तीर कुंजियों का उपयोग करें और अधिक दूरी के लिए Shift दबाए रखें।",
"keyboard.moveHelp": "स्थान बदलने के लिए तीर कुंजियों का उपयोग करें। अधिक दूरी के लिए Shift दबाए रखें।",
"keyboard.nodeMoved": "{{title}} को x {{x}}, y {{y}} पर ले जाया गया।",
"keyboard.openComment": "{{name}} की टिप्पणी खोलें",
"keyboard.position": "स्थान: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "रणनीति का चयन नहीं किया गया",
"nodes.agent.clickToViewParameterSchema": "पैरामीटर स्कीमा देखने के लिए क्लिक करें",
"nodes.agent.installPlugin.cancel": "रद्द करें",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "ID alur kerja",
"globalVar.fieldsDescription.workflowRunId": "ID eksekusi alur kerja",
"globalVar.title": "Variabel Sistem",
"keyboard.moveDraftComment": "Pindahkan draf komentar",
"keyboard.moveDraftHelp": "Tekan Enter atau Spasi untuk memulai atau mengakhiri pemindahan. Gunakan tombol panah dan tahan Shift untuk langkah lebih besar.",
"keyboard.moveHelp": "Gunakan tombol panah untuk memindahkan. Tahan Shift untuk langkah lebih besar.",
"keyboard.nodeMoved": "{{title}} dipindahkan ke x {{x}}, y {{y}}.",
"keyboard.openComment": "Buka komentar dari {{name}}",
"keyboard.position": "Posisi: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Strategi tidak dipilih",
"nodes.agent.clickToViewParameterSchema": "Klik untuk melihat skema parameter",
"nodes.agent.installPlugin.cancel": "Membatalkan",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "ID workflow",
"globalVar.fieldsDescription.workflowRunId": "ID esecuzione workflow",
"globalVar.title": "Variabili di sistema",
"keyboard.moveDraftComment": "Sposta la bozza del commento",
"keyboard.moveDraftHelp": "Premi Invio o Spazio per iniziare o terminare lo spostamento. Usa le frecce e tieni premuto Maiusc per passi più grandi.",
"keyboard.moveHelp": "Usa le frecce per spostare. Tieni premuto Maiusc per passi più grandi.",
"keyboard.nodeMoved": "{{title}} spostato a x {{x}}, y {{y}}.",
"keyboard.openComment": "Apri il commento di {{name}}",
"keyboard.position": "Posizione: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Strategia non selezionata",
"nodes.agent.clickToViewParameterSchema": "Clicca per visualizzare lo schema dei parametri",
"nodes.agent.installPlugin.cancel": "Annulla",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "ワークフローID",
"globalVar.fieldsDescription.workflowRunId": "ワークフロー実行ID",
"globalVar.title": "システム変数",
"keyboard.moveDraftComment": "コメントの下書きを移動",
"keyboard.moveDraftHelp": "Enter またはスペースキーで移動を開始・終了します。矢印キーで移動し、Shift で移動幅を大きくできます。",
"keyboard.moveHelp": "矢印キーで移動します。Shift を押すと移動幅が大きくなります。",
"keyboard.nodeMoved": "{{title}} を x {{x}}、y {{y}} に移動しました。",
"keyboard.openComment": "{{name}} のコメントを開く",
"keyboard.position": "位置x {{x}}、y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "戦略が選択されていません",
"nodes.agent.clickToViewParameterSchema": "パラメータースキーマを見るにはクリックしてください",
"nodes.agent.installPlugin.cancel": "キャンセル",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "워크플로 ID",
"globalVar.fieldsDescription.workflowRunId": "워크플로 실행 ID",
"globalVar.title": "시스템 변수",
"keyboard.moveDraftComment": "댓글 초안 이동",
"keyboard.moveDraftHelp": "Enter 또는 스페이스 키로 이동을 시작하거나 마칩니다. 방향키로 이동하고 Shift를 누르면 더 크게 이동합니다.",
"keyboard.moveHelp": "방향키로 이동합니다. Shift를 누르면 더 크게 이동합니다.",
"keyboard.nodeMoved": "{{title}}을(를) x {{x}}, y {{y}} 위치로 이동했습니다.",
"keyboard.openComment": "{{name}}의 댓글 열기",
"keyboard.position": "위치: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "전략이 선택되지 않음",
"nodes.agent.clickToViewParameterSchema": "매개변수 스키마 보려면 클릭하세요.",
"nodes.agent.installPlugin.cancel": "취소",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "ໄອດີ Workflow",
"globalVar.fieldsDescription.workflowRunId": "ໄອດີການລັນ Workflow",
"globalVar.title": "ຕົວປ່ຽນລະບົບ",
"keyboard.moveDraftComment": "ຍ້າຍຮ່າງຄຳເຫັນ",
"keyboard.moveDraftHelp": "ກົດ Enter ຫຼື Space ເພື່ອເລີ່ມ ຫຼື ສິ້ນສຸດການຍ້າຍ. ໃຊ້ປຸ່ມລູກສອນ ແລະ ກົດ Shift ຄ້າງໄວ້ເພື່ອຍ້າຍໄກຂຶ້ນ.",
"keyboard.moveHelp": "ໃຊ້ປຸ່ມລູກສອນເພື່ອຍ້າຍ. ກົດ Shift ຄ້າງໄວ້ເພື່ອຍ້າຍໄກຂຶ້ນ.",
"keyboard.nodeMoved": "ຍ້າຍ {{title}} ໄປທີ່ x {{x}}, y {{y}} ແລ້ວ.",
"keyboard.openComment": "ເປີດຄຳເຫັນຂອງ {{name}}",
"keyboard.position": "ຕຳແໜ່ງ: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "ຍັງບໍ່ໄດ້ເລືອກກົນລະຍຸດ",
"nodes.agent.clickToViewParameterSchema": "ຄລິກເພື່ອເບິ່ງ Parameter Schema",
"nodes.agent.installPlugin.cancel": "ຍົກເລີກ",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "Workflow ID",
"globalVar.fieldsDescription.workflowRunId": "Workflow run ID",
"globalVar.title": "System Variables",
"keyboard.moveDraftComment": "Conceptreactie verplaatsen",
"keyboard.moveDraftHelp": "Druk op Enter of Spatie om het verplaatsen te starten of te beëindigen. Gebruik de pijltjestoetsen en houd Shift ingedrukt voor grotere stappen.",
"keyboard.moveHelp": "Gebruik de pijltjestoetsen om te verplaatsen. Houd Shift ingedrukt voor grotere stappen.",
"keyboard.nodeMoved": "{{title}} verplaatst naar x {{x}}, y {{y}}.",
"keyboard.openComment": "Reactie van {{name}} openen",
"keyboard.position": "Positie: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Strategy not selected",
"nodes.agent.clickToViewParameterSchema": "Click to view parameter schema",
"nodes.agent.installPlugin.cancel": "Cancel",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "ID przepływu pracy",
"globalVar.fieldsDescription.workflowRunId": "ID uruchomienia przepływu pracy",
"globalVar.title": "Zmienne systemowe",
"keyboard.moveDraftComment": "Przesuń szkic komentarza",
"keyboard.moveDraftHelp": "Naciśnij Enter lub spację, aby rozpocząć lub zakończyć przesuwanie. Używaj strzałek i przytrzymaj Shift, aby zwiększyć krok.",
"keyboard.moveHelp": "Przesuwaj strzałkami. Przytrzymaj Shift, aby zwiększyć krok.",
"keyboard.nodeMoved": "Przeniesiono {{title}} do x {{x}}, y {{y}}.",
"keyboard.openComment": "Otwórz komentarz użytkownika {{name}}",
"keyboard.position": "Pozycja: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Nie wybrano strategii",
"nodes.agent.clickToViewParameterSchema": "Kliknij, aby zobaczyć schemat parametrów",
"nodes.agent.installPlugin.cancel": "Anuluj",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "ID do fluxo de trabalho",
"globalVar.fieldsDescription.workflowRunId": "ID da execução do fluxo de trabalho",
"globalVar.title": "Variáveis do sistema",
"keyboard.moveDraftComment": "Mover rascunho do comentário",
"keyboard.moveDraftHelp": "Pressione Enter ou Espaço para iniciar ou terminar o movimento. Use as setas e segure Shift para passos maiores.",
"keyboard.moveHelp": "Use as setas para mover. Segure Shift para passos maiores.",
"keyboard.nodeMoved": "{{title}} movido para x {{x}}, y {{y}}.",
"keyboard.openComment": "Abrir comentário de {{name}}",
"keyboard.position": "Posição: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Estratégia não selecionada",
"nodes.agent.clickToViewParameterSchema": "Clique para ver o esquema de parâmetros",
"nodes.agent.installPlugin.cancel": "Cancelar",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "ID flux de lucru",
"globalVar.fieldsDescription.workflowRunId": "ID rulare flux de lucru",
"globalVar.title": "Variabile de sistem",
"keyboard.moveDraftComment": "Mută ciorna comentariului",
"keyboard.moveDraftHelp": "Apasă Enter sau Spațiu pentru a începe sau încheia deplasarea. Folosește săgețile și ține apăsat Shift pentru pași mai mari.",
"keyboard.moveHelp": "Folosește săgețile pentru deplasare. Ține apăsat Shift pentru pași mai mari.",
"keyboard.nodeMoved": "{{title}} a fost mutat la x {{x}}, y {{y}}.",
"keyboard.openComment": "Deschide comentariul lui {{name}}",
"keyboard.position": "Poziție: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Strategia neselectată",
"nodes.agent.clickToViewParameterSchema": "Click pentru a vizualiza schema parametrilor",
"nodes.agent.installPlugin.cancel": "Anula",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "ID рабочего процесса",
"globalVar.fieldsDescription.workflowRunId": "ID запуска рабочего процесса",
"globalVar.title": "Системные переменные",
"keyboard.moveDraftComment": "Переместить черновик комментария",
"keyboard.moveDraftHelp": "Нажмите Enter или пробел, чтобы начать или завершить перемещение. Используйте стрелки и удерживайте Shift для увеличения шага.",
"keyboard.moveHelp": "Используйте стрелки для перемещения. Удерживайте Shift для увеличения шага.",
"keyboard.nodeMoved": "{{title}} перемещён в x {{x}}, y {{y}}.",
"keyboard.openComment": "Открыть комментарий {{name}}",
"keyboard.position": "Положение: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Стратегия не выбрана",
"nodes.agent.clickToViewParameterSchema": "Нажмите, чтобы просмотреть схему параметров",
"nodes.agent.installPlugin.cancel": "Отмена",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "ID poteka dela",
"globalVar.fieldsDescription.workflowRunId": "ID izvajanja poteka dela",
"globalVar.title": "Sistemske spremenljivke",
"keyboard.moveDraftComment": "Premakni osnutek komentarja",
"keyboard.moveDraftHelp": "Pritisnite Enter ali preslednico za začetek ali konec premikanja. Uporabite puščice in držite Shift za večje korake.",
"keyboard.moveHelp": "Premikajte s puščicami. Za večje korake držite Shift.",
"keyboard.nodeMoved": "{{title}} premaknjeno na x {{x}}, y {{y}}.",
"keyboard.openComment": "Odpri komentar uporabnika {{name}}",
"keyboard.position": "Položaj: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Strategija ni izbrana",
"nodes.agent.clickToViewParameterSchema": "Kliknite za prikaz sheme parametrov",
"nodes.agent.installPlugin.cancel": "Prekliči",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "รหัสเวิร์กโฟลว์",
"globalVar.fieldsDescription.workflowRunId": "รหัสการรันเวิร์กโฟลว์",
"globalVar.title": "ตัวแปรระบบ",
"keyboard.moveDraftComment": "ย้ายร่างความคิดเห็น",
"keyboard.moveDraftHelp": "กด Enter หรือ Space เพื่อเริ่มหรือสิ้นสุดการย้าย ใช้ปุ่มลูกศรและกด Shift ค้างไว้เพื่อย้ายครั้งละมากขึ้น",
"keyboard.moveHelp": "ใช้ปุ่มลูกศรเพื่อย้าย กด Shift ค้างไว้เพื่อย้ายครั้งละมากขึ้น",
"keyboard.nodeMoved": "ย้าย {{title}} ไปที่ x {{x}}, y {{y}} แล้ว",
"keyboard.openComment": "เปิดความคิดเห็นของ {{name}}",
"keyboard.position": "ตำแหน่ง: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "ไม่ได้เลือกกลยุทธ์",
"nodes.agent.clickToViewParameterSchema": "คลิกเพื่อดูโครงร่างพารามิเตอร์",
"nodes.agent.installPlugin.cancel": "ยกเลิก",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "İş Akışı Kimliği",
"globalVar.fieldsDescription.workflowRunId": "İş akışı yürütme kimliği",
"globalVar.title": "Sistem Değişkenleri",
"keyboard.moveDraftComment": "Yorum taslağını taşı",
"keyboard.moveDraftHelp": "Taşımayı başlatmak veya bitirmek için Enter ya da Boşluk tuşuna basın. Ok tuşlarını kullanın; daha büyük adımlar için Shift tuşunu basılı tutun.",
"keyboard.moveHelp": "Taşımak için ok tuşlarını kullanın. Daha büyük adımlar için Shift tuşunu basılı tutun.",
"keyboard.nodeMoved": "{{title}}, x {{x}}, y {{y}} konumuna taşındı.",
"keyboard.openComment": "{{name}} adlı kişinin yorumunu aç",
"keyboard.position": "Konum: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Strateji seçilmedi",
"nodes.agent.clickToViewParameterSchema": "Parametre şemasını görüntülemek için tıklayın",
"nodes.agent.installPlugin.cancel": "İptal",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "ID робочого процесу",
"globalVar.fieldsDescription.workflowRunId": "ID запуску робочого процесу",
"globalVar.title": "Системні змінні",
"keyboard.moveDraftComment": "Перемістити чернетку коментаря",
"keyboard.moveDraftHelp": "Натисніть Enter або пробіл, щоб почати чи завершити переміщення. Використовуйте стрілки й утримуйте Shift для збільшення кроку.",
"keyboard.moveHelp": "Використовуйте стрілки для переміщення. Утримуйте Shift для збільшення кроку.",
"keyboard.nodeMoved": "{{title}} переміщено до x {{x}}, y {{y}}.",
"keyboard.openComment": "Відкрити коментар {{name}}",
"keyboard.position": "Положення: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Стратегію не обрано",
"nodes.agent.clickToViewParameterSchema": "Натисніть, щоб переглянути схему параметрів",
"nodes.agent.installPlugin.cancel": "Скасувати",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "ID quy trình làm việc",
"globalVar.fieldsDescription.workflowRunId": "ID lần chạy quy trình làm việc",
"globalVar.title": "Biến hệ thống",
"keyboard.moveDraftComment": "Di chuyển bản nháp bình luận",
"keyboard.moveDraftHelp": "Nhấn Enter hoặc dấu cách để bắt đầu hoặc kết thúc di chuyển. Dùng phím mũi tên và giữ Shift để di chuyển xa hơn.",
"keyboard.moveHelp": "Dùng phím mũi tên để di chuyển. Giữ Shift để di chuyển xa hơn.",
"keyboard.nodeMoved": "Đã di chuyển {{title}} đến x {{x}}, y {{y}}.",
"keyboard.openComment": "Mở bình luận của {{name}}",
"keyboard.position": "Vị trí: x {{x}}, y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "Chiến lược không được chọn",
"nodes.agent.clickToViewParameterSchema": "Nhấp để xem sơ đồ tham số",
"nodes.agent.installPlugin.cancel": "Hủy",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "工作流 ID",
"globalVar.fieldsDescription.workflowRunId": "工作流运行 ID",
"globalVar.title": "系统变量",
"keyboard.moveDraftComment": "移动批注草稿",
"keyboard.moveDraftHelp": "按 Enter 或空格开始或结束移动。使用方向键移动,按住 Shift 加速。",
"keyboard.moveHelp": "使用方向键移动,按住 Shift 加速。",
"keyboard.nodeMoved": "已将 {{title}} 移至 x {{x}}y {{y}}。",
"keyboard.openComment": "打开 {{name}} 的批注",
"keyboard.position": "位置x {{x}}y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "未选择策略",
"nodes.agent.clickToViewParameterSchema": "点击查看参数 schema",
"nodes.agent.installPlugin.cancel": "取消",

View File

@ -352,6 +352,12 @@
"globalVar.fieldsDescription.workflowId": "工作流程 ID",
"globalVar.fieldsDescription.workflowRunId": "工作流程執行 ID",
"globalVar.title": "系統變數",
"keyboard.moveDraftComment": "移動批註草稿",
"keyboard.moveDraftHelp": "按 Enter 或空白鍵開始或結束移動。使用方向鍵移動,按住 Shift 加速。",
"keyboard.moveHelp": "使用方向鍵移動,按住 Shift 加速。",
"keyboard.nodeMoved": "已將 {{title}} 移至 x {{x}}y {{y}}。",
"keyboard.openComment": "開啟 {{name}} 的批註",
"keyboard.position": "位置x {{x}}y {{y}}",
"nodes.agent.checkList.strategyNotSelected": "未選擇策略",
"nodes.agent.clickToViewParameterSchema": "點擊查看參數架構",
"nodes.agent.installPlugin.cancel": "取消",

View File

@ -104,7 +104,11 @@ export default defineConfig(({ command, mode, isPreview }) => {
return [tailwindcss()]
}),
optimizeDeps: {
include: ['vite-plus/test/browser'],
include: [
'vite-plus/test/browser',
'dayjs/plugin/relativeTime',
'react-textarea-autosize',
],
},
test: {
name: 'browser',