mirror of
https://github.com/langgenius/dify.git
synced 2026-09-09 05:41:00 +08:00
feat: support keyboard movement for workflow nodes and comments (#41967)
This commit is contained in:
parent
67f7a5517a
commit
93d6ff3889
@ -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()
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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(
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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()} />)
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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')
|
||||
})
|
||||
@ -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(() => {
|
||||
|
||||
@ -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 })
|
||||
},
|
||||
)
|
||||
})
|
||||
@ -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),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
18
web/app/components/workflow/utils/keyboard-movement.ts
Normal file
18
web/app/components/workflow/utils/keyboard-movement.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
@ -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": "إلغاء",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "لغو",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "रद्द करें",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "キャンセル",
|
||||
|
||||
@ -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": "취소",
|
||||
|
||||
@ -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": "ຍົກເລີກ",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Отмена",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "ยกเลิก",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Скасувати",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "取消",
|
||||
|
||||
@ -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": "取消",
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user