mirror of https://github.com/langgenius/dify.git
Merge branch 'feat/collaboration' into deploy/dev
This commit is contained in:
commit
ca46718a88
|
|
@ -35,6 +35,7 @@ export class CollaborationManager {
|
|||
private nodePanelPresence: NodePanelPresenceMap = {}
|
||||
private activeConnections = new Set<string>()
|
||||
private isUndoRedoInProgress = false
|
||||
private pendingInitialSync = false
|
||||
|
||||
private getNodePanelPresenceSnapshot(): NodePanelPresenceMap {
|
||||
const snapshot: NodePanelPresenceMap = {}
|
||||
|
|
@ -741,6 +742,8 @@ export class CollaborationManager {
|
|||
.map(node => node.id),
|
||||
)
|
||||
|
||||
this.pendingInitialSync = false
|
||||
|
||||
const updatedNodes = Array
|
||||
.from(this.nodesMap.values())
|
||||
.map((node: Node) => {
|
||||
|
|
@ -790,6 +793,8 @@ export class CollaborationManager {
|
|||
const updatedEdges = Array.from(this.edgesMap.values())
|
||||
console.log('Updating React edges from subscription')
|
||||
|
||||
this.pendingInitialSync = false
|
||||
|
||||
// Call ReactFlow's native setter directly to avoid triggering collaboration
|
||||
state.setEdges(updatedEdges)
|
||||
})
|
||||
|
|
@ -852,6 +857,11 @@ export class CollaborationManager {
|
|||
this.eventEmitter.emit('syncRequest', {})
|
||||
}
|
||||
}
|
||||
else if (update.type === 'graph_resync_request') {
|
||||
console.log('Received graph resync request from collaborator')
|
||||
if (this.isLeader)
|
||||
this.broadcastCurrentGraph()
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('online_users', (data: { users: OnlineUser[]; leader?: string }) => {
|
||||
|
|
@ -898,6 +908,11 @@ export class CollaborationManager {
|
|||
const wasLeader = this.isLeader
|
||||
this.isLeader = data.isLeader
|
||||
|
||||
if (this.isLeader)
|
||||
this.pendingInitialSync = false
|
||||
else
|
||||
this.requestInitialSyncIfNeeded()
|
||||
|
||||
if (wasLeader !== this.isLeader)
|
||||
this.eventEmitter.emit('leaderChange', this.isLeader)
|
||||
}
|
||||
|
|
@ -912,6 +927,10 @@ export class CollaborationManager {
|
|||
console.log(`Collaboration: I am now the ${this.isLeader ? 'Leader' : 'Follower'}.`)
|
||||
this.eventEmitter.emit('leaderChange', this.isLeader)
|
||||
}
|
||||
if (this.isLeader)
|
||||
this.pendingInitialSync = false
|
||||
else
|
||||
this.requestInitialSyncIfNeeded()
|
||||
})
|
||||
|
||||
socket.on('status', (data: { isLeader: boolean }) => {
|
||||
|
|
@ -920,11 +939,16 @@ export class CollaborationManager {
|
|||
console.log(`Collaboration: I am now the ${this.isLeader ? 'Leader' : 'Follower'}.`)
|
||||
this.eventEmitter.emit('leaderChange', this.isLeader)
|
||||
}
|
||||
if (this.isLeader)
|
||||
this.pendingInitialSync = false
|
||||
else
|
||||
this.requestInitialSyncIfNeeded()
|
||||
})
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('WebSocket connected successfully')
|
||||
this.eventEmitter.emit('stateChange', { isConnected: true })
|
||||
this.pendingInitialSync = true
|
||||
})
|
||||
|
||||
socket.on('disconnect', (reason: string) => {
|
||||
|
|
@ -932,6 +956,7 @@ export class CollaborationManager {
|
|||
this.cursors = {}
|
||||
this.isLeader = false
|
||||
this.leaderId = null
|
||||
this.pendingInitialSync = false
|
||||
this.eventEmitter.emit('stateChange', { isConnected: false })
|
||||
this.eventEmitter.emit('cursors', {})
|
||||
})
|
||||
|
|
@ -945,6 +970,49 @@ export class CollaborationManager {
|
|||
console.error('WebSocket error:', error)
|
||||
})
|
||||
}
|
||||
|
||||
// We currently only relay CRDT updates; the server doesn't persist them.
|
||||
// When a follower joins mid-session, it might miss earlier broadcasts and render stale data.
|
||||
// This lightweight checkpoint asks the leader to rebroadcast the latest graph snapshot once.
|
||||
private requestInitialSyncIfNeeded(): void {
|
||||
if (!this.pendingInitialSync) return
|
||||
if (this.isLeader) {
|
||||
this.pendingInitialSync = false
|
||||
return
|
||||
}
|
||||
|
||||
this.emitGraphResyncRequest()
|
||||
this.pendingInitialSync = false
|
||||
}
|
||||
|
||||
private emitGraphResyncRequest(): void {
|
||||
if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return
|
||||
|
||||
const socket = webSocketClient.getSocket(this.currentAppId)
|
||||
if (!socket) return
|
||||
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'graph_resync_request',
|
||||
data: { timestamp: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
private broadcastCurrentGraph(): void {
|
||||
if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return
|
||||
if (!this.doc) return
|
||||
|
||||
const socket = webSocketClient.getSocket(this.currentAppId)
|
||||
if (!socket) return
|
||||
|
||||
try {
|
||||
const snapshot = this.doc.export({ mode: 'snapshot' })
|
||||
socket.emit('graph_event', snapshot)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to broadcast graph snapshot:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const collaborationManager = new CollaborationManager()
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ import { ControlMode } from '../types'
|
|||
import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
|
||||
import { createWorkflowComment, createWorkflowCommentReply, deleteWorkflowComment, deleteWorkflowCommentReply, fetchWorkflowComment, fetchWorkflowComments, resolveWorkflowComment, updateWorkflowComment, updateWorkflowCommentReply } from '@/service/workflow-comment'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
export const useWorkflowComment = () => {
|
||||
const params = useParams()
|
||||
const appId = params.appId as string
|
||||
const reactflow = useReactFlow()
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
const setControlMode = useStore(s => s.setControlMode)
|
||||
const pendingComment = useStore(s => s.pendingComment)
|
||||
const setPendingComment = useStore(s => s.setPendingComment)
|
||||
const setActiveCommentId = useStore(s => s.setActiveCommentId)
|
||||
|
|
@ -31,6 +31,9 @@ export const useWorkflowComment = () => {
|
|||
const setReplyUpdating = useStore(s => s.setReplyUpdating)
|
||||
const commentDetailCache = useStore(s => s.commentDetailCache)
|
||||
const setCommentDetailCache = useStore(s => s.setCommentDetailCache)
|
||||
const rightPanelWidth = useStore(s => s.rightPanelWidth)
|
||||
const nodePanelWidth = useStore(s => s.nodePanelWidth)
|
||||
const { userProfile } = useAppContext()
|
||||
const commentDetailCacheRef = useRef<Record<string, WorkflowCommentDetail>>(commentDetailCache)
|
||||
const activeCommentIdRef = useRef<string | null>(null)
|
||||
|
||||
|
|
@ -113,16 +116,63 @@ export const useWorkflowComment = () => {
|
|||
|
||||
console.log('Comment created successfully:', newComment)
|
||||
|
||||
const createdAt = (newComment as any)?.created_at
|
||||
const createdByAccount = {
|
||||
id: userProfile?.id ?? '',
|
||||
name: userProfile?.name ?? '',
|
||||
email: userProfile?.email ?? '',
|
||||
avatar_url: userProfile?.avatar_url || userProfile?.avatar || undefined,
|
||||
}
|
||||
|
||||
const composedComment: WorkflowCommentList = {
|
||||
id: newComment.id,
|
||||
position_x: flowPosition.x,
|
||||
position_y: flowPosition.y,
|
||||
content,
|
||||
created_by: createdByAccount.id,
|
||||
created_by_account: createdByAccount,
|
||||
created_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
resolved: false,
|
||||
mention_count: mentionedUserIds.length,
|
||||
reply_count: 0,
|
||||
participants: createdByAccount.id ? [createdByAccount] : [],
|
||||
}
|
||||
|
||||
const composedDetail: WorkflowCommentDetail = {
|
||||
id: newComment.id,
|
||||
position_x: flowPosition.x,
|
||||
position_y: flowPosition.y,
|
||||
content,
|
||||
created_by: createdByAccount.id,
|
||||
created_by_account: createdByAccount,
|
||||
created_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
resolved: false,
|
||||
replies: [],
|
||||
mentions: mentionedUserIds.map(mentionedId => ({
|
||||
mentioned_user_id: mentionedId,
|
||||
mentioned_user_account: null,
|
||||
reply_id: null,
|
||||
})),
|
||||
}
|
||||
|
||||
setComments([...comments, composedComment])
|
||||
commentDetailCacheRef.current = {
|
||||
...commentDetailCacheRef.current,
|
||||
[newComment.id]: composedDetail,
|
||||
}
|
||||
setCommentDetailCache(commentDetailCacheRef.current)
|
||||
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
|
||||
await loadComments()
|
||||
setPendingComment(null)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to create comment:', error)
|
||||
setPendingComment(null)
|
||||
}
|
||||
}, [appId, pendingComment, setPendingComment, loadComments, reactflow])
|
||||
}, [appId, pendingComment, setPendingComment, reactflow, comments, setComments, userProfile, setCommentDetailCache])
|
||||
|
||||
const handleCommentCancel = useCallback(() => {
|
||||
setPendingComment(null)
|
||||
|
|
@ -142,9 +192,16 @@ export const useWorkflowComment = () => {
|
|||
const cachedDetail = commentDetailCacheRef.current[comment.id]
|
||||
setActiveComment(cachedDetail || comment)
|
||||
|
||||
let horizontalOffsetPx = 220
|
||||
const hasSelectedNode = reactflow.getNodes().some(node => node.data?.selected)
|
||||
const commentPanelWidth = controlMode === ControlMode.Comment ? 420 : 0
|
||||
const fallbackPanelWidth = (hasSelectedNode ? nodePanelWidth : 0) + commentPanelWidth
|
||||
const effectivePanelWidth = Math.max(rightPanelWidth ?? 0, fallbackPanelWidth)
|
||||
|
||||
const baseHorizontalOffsetPx = 220
|
||||
const panelCompensationPx = effectivePanelWidth / 2
|
||||
const desiredHorizontalOffsetPx = baseHorizontalOffsetPx + panelCompensationPx
|
||||
const maxOffset = Math.max(0, (window.innerWidth / 2) - 60)
|
||||
horizontalOffsetPx = Math.min(horizontalOffsetPx, maxOffset)
|
||||
const horizontalOffsetPx = Math.min(desiredHorizontalOffsetPx, maxOffset)
|
||||
|
||||
reactflow.setCenter(
|
||||
comment.position_x + horizontalOffsetPx,
|
||||
|
|
@ -175,7 +232,18 @@ export const useWorkflowComment = () => {
|
|||
finally {
|
||||
setActiveCommentLoading(false)
|
||||
}
|
||||
}, [appId, reactflow, setActiveComment, setActiveCommentId, setActiveCommentLoading, setCommentDetailCache, setControlMode, setPendingComment])
|
||||
}, [
|
||||
appId,
|
||||
controlMode,
|
||||
nodePanelWidth,
|
||||
reactflow,
|
||||
rightPanelWidth,
|
||||
setActiveComment,
|
||||
setActiveCommentId,
|
||||
setActiveCommentLoading,
|
||||
setCommentDetailCache,
|
||||
setPendingComment,
|
||||
])
|
||||
|
||||
const handleCommentResolve = useCallback(async (commentId: string) => {
|
||||
if (!appId) return
|
||||
|
|
|
|||
|
|
@ -422,7 +422,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||
<CandidateNode />
|
||||
<CommentManager />
|
||||
<div
|
||||
className='pointer-events-none absolute left-0 top-0 z-10 flex w-12 items-center justify-center p-1 pl-2'
|
||||
className='pointer-events-none absolute left-0 top-0 z-[70] flex w-12 items-center justify-center p-1 pl-2'
|
||||
style={{ height: controlHeight }}
|
||||
>
|
||||
<Control />
|
||||
|
|
@ -528,7 +528,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||
defaultViewport={viewport}
|
||||
multiSelectionKeyCode={null}
|
||||
deleteKeyCode={null}
|
||||
nodesDraggable={!nodesReadOnly}
|
||||
nodesDraggable={!nodesReadOnly && controlMode !== ControlMode.Comment}
|
||||
nodesConnectable={!nodesReadOnly}
|
||||
nodesFocusable={!nodesReadOnly}
|
||||
edgesFocusable={!nodesReadOnly}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next'
|
|||
import type { NodeProps } from '../../types'
|
||||
import {
|
||||
BlockEnum,
|
||||
ControlMode,
|
||||
NodeRunningStatus,
|
||||
} from '../../types'
|
||||
import {
|
||||
|
|
@ -72,6 +73,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
const { userProfile } = useAppContext()
|
||||
const appId = useStore(s => s.appId)
|
||||
const { nodePanelPresence } = useCollaboration(appId as string)
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
|
||||
const currentUserPresence = useMemo(() => {
|
||||
const userId = userProfile?.id || ''
|
||||
|
|
@ -196,6 +198,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
className={cn(
|
||||
'group relative pb-1 shadow-xs',
|
||||
'rounded-[15px] border border-transparent',
|
||||
(controlMode === ControlMode.Comment) && 'hover:cursor-none',
|
||||
(data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop) && 'w-[240px] bg-workflow-block-bg',
|
||||
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'flex h-full w-full flex-col border-workflow-block-border bg-workflow-block-bg-transparent',
|
||||
!data._runningStatus && 'hover:shadow-lg',
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ export const useNodeIterationInteractions = () => {
|
|||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
|
||||
const currentNode = nodes.find(n => n.id === nodeId)!
|
||||
const childrenNodes = nodes.filter(n => n.parentId === nodeId)
|
||||
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_ITERATION_START_NODE)
|
||||
if (!childrenNodes.length)
|
||||
return
|
||||
let rightNode: Node
|
||||
let bottomNode: Node
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import produce from 'immer'
|
||||
|
|
@ -26,6 +27,9 @@ const useConfig = (id: string, payload: LoopNodeType) => {
|
|||
|
||||
const { inputs, setInputs } = useNodeCrud<LoopNodeType>(id, payload)
|
||||
const inputsRef = useRef(inputs)
|
||||
useEffect(() => {
|
||||
inputsRef.current = inputs
|
||||
}, [inputs])
|
||||
const handleInputsChange = useCallback((newInputs: LoopNodeType) => {
|
||||
inputsRef.current = newInputs
|
||||
setInputs(newInputs)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ export const useNodeLoopInteractions = () => {
|
|||
const handleNodeLoopRerender = useCallback((nodeId: string) => {
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
const currentNode = nodes.find(n => n.id === nodeId)!
|
||||
const childrenNodes = nodes.filter(n => n.parentId === nodeId)
|
||||
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_LOOP_START_NODE)
|
||||
if (!childrenNodes.length)
|
||||
return
|
||||
let rightNode: Node
|
||||
let bottomNode: Node
|
||||
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-10'>
|
||||
<PortalToFollowElemContent className='z-[60]'>
|
||||
<div className='w-[192px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
|
||||
{
|
||||
ZOOM_IN_OUT_OPTIONS.map((options, i) => (
|
||||
|
|
|
|||
|
|
@ -328,6 +328,9 @@ const translation = {
|
|||
zoomTo50: 'Auf 50% vergrößern',
|
||||
zoomTo100: 'Auf 100% vergrößern',
|
||||
zoomToFit: 'An Bildschirm anpassen',
|
||||
showUserComments: 'Kommentare',
|
||||
showUserCursors: 'Cursor von Mitarbeitenden',
|
||||
showMiniMap: 'Minikarte',
|
||||
selectionAlignment: 'Ausrichtung der Auswahl',
|
||||
alignLeft: 'Links',
|
||||
alignTop: 'Nach oben',
|
||||
|
|
|
|||
|
|
@ -328,6 +328,9 @@ const translation = {
|
|||
zoomTo50: 'Zoom al 50%',
|
||||
zoomTo100: 'Zoom al 100%',
|
||||
zoomToFit: 'Ajustar al tamaño',
|
||||
showUserComments: 'Comentarios',
|
||||
showUserCursors: 'Cursores de colaboradores',
|
||||
showMiniMap: 'Minimapa',
|
||||
alignTop: 'Arriba',
|
||||
alignBottom: 'Abajo',
|
||||
alignNodes: 'Alinear nodos',
|
||||
|
|
|
|||
|
|
@ -328,6 +328,9 @@ const translation = {
|
|||
zoomTo50: 'بزرگنمایی به 50%',
|
||||
zoomTo100: 'بزرگنمایی به 100%',
|
||||
zoomToFit: 'تناسب با اندازه',
|
||||
showUserComments: 'نظرات',
|
||||
showUserCursors: 'نشانگرهای همکاران',
|
||||
showMiniMap: 'نقشه کوچک',
|
||||
horizontal: 'افقی',
|
||||
alignBottom: 'پایین',
|
||||
alignRight: 'راست',
|
||||
|
|
|
|||
|
|
@ -328,6 +328,9 @@ const translation = {
|
|||
zoomTo50: 'Zoomer à 50%',
|
||||
zoomTo100: 'Zoomer à 100%',
|
||||
zoomToFit: 'Zoomer pour ajuster',
|
||||
showUserComments: 'Commentaires',
|
||||
showUserCursors: 'Curseurs des collaborateurs',
|
||||
showMiniMap: 'Mini-carte',
|
||||
alignBottom: 'Bas',
|
||||
alignLeft: 'Gauche',
|
||||
alignCenter: 'Centre',
|
||||
|
|
|
|||
|
|
@ -339,6 +339,9 @@ const translation = {
|
|||
zoomTo50: '50% पर ज़ूम करें',
|
||||
zoomTo100: '100% पर ज़ूम करें',
|
||||
zoomToFit: 'फिट करने के लिए ज़ूम करें',
|
||||
showUserComments: 'टिप्पणियाँ',
|
||||
showUserCursors: 'सहयोगी कर्सर',
|
||||
showMiniMap: 'मिनी मानचित्र',
|
||||
alignRight: 'दाएं',
|
||||
alignLeft: 'बाएं',
|
||||
alignTop: 'शीर्ष',
|
||||
|
|
|
|||
|
|
@ -317,6 +317,9 @@ const translation = {
|
|||
alignCenter: 'Pusat',
|
||||
zoomOut: 'Perkecil',
|
||||
zoomToFit: 'Perbesar agar sesuai',
|
||||
showUserComments: 'Komentar',
|
||||
showUserCursors: 'Kursor kolaborator',
|
||||
showMiniMap: 'Peta mini',
|
||||
vertical: 'Vertikal',
|
||||
alignTop: 'Puncak',
|
||||
alignMiddle: 'Tengah',
|
||||
|
|
|
|||
|
|
@ -342,6 +342,9 @@ const translation = {
|
|||
zoomTo50: 'Zoom al 50%',
|
||||
zoomTo100: 'Zoom al 100%',
|
||||
zoomToFit: 'Zoom per Adattare',
|
||||
showUserComments: 'Commenti',
|
||||
showUserCursors: 'Cursori dei collaboratori',
|
||||
showMiniMap: 'Mini mappa',
|
||||
alignRight: 'A destra',
|
||||
selectionAlignment: 'Allineamento della selezione',
|
||||
alignBottom: 'In basso',
|
||||
|
|
|
|||
|
|
@ -333,6 +333,9 @@ const translation = {
|
|||
zoomTo50: '50% サイズ',
|
||||
zoomTo100: '等倍表示',
|
||||
zoomToFit: '画面に合わせる',
|
||||
showUserComments: 'コメント',
|
||||
showUserCursors: '協働者のカーソル',
|
||||
showMiniMap: 'ミニマップ',
|
||||
horizontal: '水平',
|
||||
alignBottom: '下',
|
||||
alignNodes: 'ノードを整列',
|
||||
|
|
|
|||
|
|
@ -349,6 +349,9 @@ const translation = {
|
|||
zoomTo50: '50% 로 확대',
|
||||
zoomTo100: '100% 로 확대',
|
||||
zoomToFit: '화면에 맞게 확대',
|
||||
showUserComments: '댓글',
|
||||
showUserCursors: '협업자 커서',
|
||||
showMiniMap: '미니맵',
|
||||
alignCenter: '중앙',
|
||||
alignRight: '오른쪽',
|
||||
alignLeft: '왼쪽',
|
||||
|
|
|
|||
|
|
@ -302,6 +302,9 @@ const translation = {
|
|||
alignCenter: 'Centrum',
|
||||
alignRight: 'Prawy',
|
||||
alignNodes: 'Wyrównywanie węzłów',
|
||||
showUserComments: 'Komentarze',
|
||||
showUserCursors: 'Kursory współpracowników',
|
||||
showMiniMap: 'Minimapa',
|
||||
selectionAlignment: 'Wyrównanie zaznaczenia',
|
||||
horizontal: 'Poziomy',
|
||||
distributeVertical: 'Rozmieść pionowo',
|
||||
|
|
|
|||
|
|
@ -302,6 +302,9 @@ const translation = {
|
|||
alignLeft: 'Esquerda',
|
||||
alignBottom: 'Inferior',
|
||||
distributeHorizontal: 'Distribuir horizontalmente',
|
||||
showUserComments: 'Comentários',
|
||||
showUserCursors: 'Cursores dos colaboradores',
|
||||
showMiniMap: 'Minimapa',
|
||||
alignMiddle: 'Meio',
|
||||
alignRight: 'Direita',
|
||||
horizontal: 'Horizontal',
|
||||
|
|
|
|||
|
|
@ -304,6 +304,9 @@ const translation = {
|
|||
alignMiddle: 'Mijloc',
|
||||
distributeVertical: 'Distribuie vertical',
|
||||
alignCenter: 'Centru',
|
||||
showUserComments: 'Comentarii',
|
||||
showUserCursors: 'Cursoarele colaboratorilor',
|
||||
showMiniMap: 'Mini-hartă',
|
||||
distributeHorizontal: 'Distribuie orizontal',
|
||||
alignBottom: 'Jos',
|
||||
alignTop: 'Sus',
|
||||
|
|
|
|||
|
|
@ -300,6 +300,9 @@ const translation = {
|
|||
alignBottom: 'Вниз',
|
||||
alignRight: 'Вправо',
|
||||
distributeHorizontal: 'Распределить по горизонтали',
|
||||
showUserComments: 'Комментарии',
|
||||
showUserCursors: 'Курсоры участников',
|
||||
showMiniMap: 'Мини-карта',
|
||||
alignMiddle: 'По центру',
|
||||
vertical: 'Вертикальный',
|
||||
alignCenter: 'Центр',
|
||||
|
|
|
|||
|
|
@ -300,6 +300,9 @@ const translation = {
|
|||
alignBottom: 'Spodaj',
|
||||
alignCenter: 'Center',
|
||||
distributeVertical: 'Razporedi navpično',
|
||||
showUserComments: 'Komentarji',
|
||||
showUserCursors: 'Kazalci sodelavcev',
|
||||
showMiniMap: 'Mini zemljevid',
|
||||
alignRight: 'Desno',
|
||||
alignTop: 'Vrh',
|
||||
vertical: 'Navpičen',
|
||||
|
|
|
|||
|
|
@ -302,6 +302,9 @@ const translation = {
|
|||
horizontal: 'แนวนอน',
|
||||
vertical: 'แนวตั้ง',
|
||||
alignTop: 'ด้านบน',
|
||||
showUserComments: 'ความคิดเห็น',
|
||||
showUserCursors: 'เคอร์เซอร์ของผู้ร่วมงาน',
|
||||
showMiniMap: 'แผนที่ย่อ',
|
||||
distributeVertical: 'ระยะห่างแนวตั้ง',
|
||||
alignLeft: 'ซ้าย',
|
||||
selectionAlignment: 'การจัดตําแหน่งการเลือก',
|
||||
|
|
|
|||
|
|
@ -301,6 +301,9 @@ const translation = {
|
|||
alignLeft: 'Sol',
|
||||
alignNodes: 'Düğümleri Hizala',
|
||||
vertical: 'Dikey',
|
||||
showUserComments: 'Yorumlar',
|
||||
showUserCursors: 'İşbirlikçi imleçleri',
|
||||
showMiniMap: 'Mini harita',
|
||||
alignRight: 'Sağ',
|
||||
alignTop: 'Üst',
|
||||
alignBottom: 'Alt',
|
||||
|
|
|
|||
|
|
@ -302,6 +302,9 @@ const translation = {
|
|||
alignBottom: 'Низ',
|
||||
alignLeft: 'Ліворуч',
|
||||
alignTop: 'Верх',
|
||||
showUserComments: 'Коментарі',
|
||||
showUserCursors: 'Курсори співучасників',
|
||||
showMiniMap: 'Мінікарта',
|
||||
horizontal: 'Горизонтальний',
|
||||
alignMiddle: 'По центру',
|
||||
distributeVertical: 'Розподілити по вертикалі',
|
||||
|
|
|
|||
|
|
@ -300,6 +300,9 @@ const translation = {
|
|||
alignMiddle: 'Giữa',
|
||||
alignRight: 'Phải',
|
||||
alignNodes: 'Căn chỉnh các nút',
|
||||
showUserComments: 'Bình luận',
|
||||
showUserCursors: 'Con trỏ của cộng tác viên',
|
||||
showMiniMap: 'Bản đồ nhỏ',
|
||||
alignLeft: 'Trái',
|
||||
horizontal: 'Ngang',
|
||||
alignCenter: 'Giữa',
|
||||
|
|
|
|||
|
|
@ -334,6 +334,9 @@ const translation = {
|
|||
zoomTo50: '缩放到 50%',
|
||||
zoomTo100: '放大到 100%',
|
||||
zoomToFit: '自适应视图',
|
||||
showUserComments: '评论',
|
||||
showUserCursors: '协作者光标',
|
||||
showMiniMap: '小地图',
|
||||
alignNodes: '对齐节点',
|
||||
alignLeft: '左对齐',
|
||||
alignCenter: '居中对齐',
|
||||
|
|
|
|||
|
|
@ -328,6 +328,9 @@ const translation = {
|
|||
zoomTo50: '縮放到 50%',
|
||||
zoomTo100: '放大到 100%',
|
||||
zoomToFit: '自適應視圖',
|
||||
showUserComments: '評論',
|
||||
showUserCursors: '協作者游標',
|
||||
showMiniMap: '小地圖',
|
||||
alignNodes: '對齊節點',
|
||||
distributeVertical: '垂直等間距',
|
||||
alignLeft: '左對齊',
|
||||
|
|
|
|||
Loading…
Reference in New Issue