feat: code editor cursor sync

This commit is contained in:
hjlarry
2026-01-29 14:28:30 +08:00
parent d73a36d6bc
commit a5ace48f96
4 changed files with 444 additions and 27 deletions

View File

@@ -0,0 +1,388 @@
import type { OnMount } from '@monaco-editor/react'
import type { OnlineUser } from '@/app/components/workflow/collaboration/types'
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { skillCollaborationManager } from '@/app/components/workflow/collaboration/skills/skill-collaboration-manager'
import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color'
import { useAppContext } from '@/context/app-context'
const CURSOR_THROTTLE_MS = 200
const CURSOR_TTL_MS = 15000
const SELECTION_ALPHA = 0.2
type MonacoEditor = Parameters<OnMount>[0]
type MonacoModel = Exclude<ReturnType<MonacoEditor['getModel']>, null>
type MonacoDecorations = Parameters<ReturnType<MonacoEditor['createDecorationsCollection']>['set']>[0]
type MonacoDecorationsCollection = ReturnType<MonacoEditor['createDecorationsCollection']>
type MonacoDecoration = MonacoDecorations extends readonly (infer T)[] ? T : never
type SkillCursorInfo = {
userId: string
start: number
end: number
timestamp: number
}
type SkillCursorMap = Record<string, SkillCursorInfo>
type CursorOverlayItem = {
userId: string
x: number
y: number
height: number
name: string
color: string
}
type CursorRenderState = {
positions: CursorOverlayItem[]
}
type CursorRenderAction
= | { type: 'set', positions: CursorOverlayItem[] }
| { type: 'clear' }
const cursorRenderReducer = (_state: CursorRenderState, action: CursorRenderAction): CursorRenderState => {
if (action.type === 'clear')
return { positions: [] }
return { positions: action.positions }
}
const hashUserId = (userId: string): string => {
let hash = 0
for (let i = 0; i < userId.length; i++)
hash = (hash * 31 + userId.charCodeAt(i)) >>> 0
return hash.toString(36)
}
const getSelectionClassName = (userId: string): string => `skill-code-selection-${hashUserId(userId)}`
const hexToRgba = (hex: string, alpha: number): string => {
const clean = hex.replace('#', '')
if (clean.length !== 6)
return `rgba(0, 0, 0, ${alpha})`
const r = Number.parseInt(clean.slice(0, 2), 16)
const g = Number.parseInt(clean.slice(2, 4), 16)
const b = Number.parseInt(clean.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
const clampOffset = (offset: number, max: number) => Math.max(0, Math.min(offset, max))
const getSelectionRange = (model: MonacoModel, start: number, end: number) => {
const maxOffset = model.getValueLength()
const safeStart = clampOffset(start, maxOffset)
const safeEnd = clampOffset(end, maxOffset)
const startPos = model.getPositionAt(Math.min(safeStart, safeEnd))
const endPos = model.getPositionAt(Math.max(safeStart, safeEnd))
return {
startLineNumber: startPos.lineNumber,
startColumn: startPos.column,
endLineNumber: endPos.lineNumber,
endColumn: endPos.column,
}
}
type UseSkillCodeCursorsProps = {
editor: MonacoEditor | null
fileId: string | null
enabled: boolean
}
export const useSkillCodeCursors = ({ editor, fileId, enabled }: UseSkillCodeCursorsProps) => {
const { userProfile } = useAppContext()
const myUserId = userProfile?.id || null
const [cursorMap, setCursorMap] = useState<SkillCursorMap>({})
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([])
const [renderState, dispatchRender] = useReducer(cursorRenderReducer, { positions: [] })
const cursorMapRef = useRef<SkillCursorMap>({})
const rafIdRef = useRef<number | null>(null)
const decorationCollectionRef = useRef<MonacoDecorationsCollection | null>(null)
const styleRef = useRef<HTMLStyleElement | null>(null)
const pendingCursorRef = useRef<{ start: number, end: number } | null>(null)
const lastCursorRef = useRef<{ start: number, end: number } | null>(null)
const throttleTimerRef = useRef<number | null>(null)
const effectiveCursorMap = useMemo(() => (enabled && fileId ? cursorMap : {}), [cursorMap, enabled, fileId])
useEffect(() => {
cursorMapRef.current = effectiveCursorMap
}, [effectiveCursorMap])
useEffect(() => {
return collaborationManager.onOnlineUsersUpdate(setOnlineUsers)
}, [])
useEffect(() => {
if (!enabled || !fileId)
return
return skillCollaborationManager.onCursorUpdate(fileId, (nextCursors) => {
setCursorMap(nextCursors)
})
}, [enabled, fileId])
const onlineUserMap = useMemo(() => {
return onlineUsers.reduce<Record<string, OnlineUser>>((acc, user) => {
acc[user.user_id] = user
return acc
}, {})
}, [onlineUsers])
const updateSelectionStyles = useCallback((userIds: string[]) => {
if (typeof document === 'undefined')
return
if (!styleRef.current) {
const style = document.createElement('style')
style.dataset.skillCodeCursor = 'true'
document.head.appendChild(style)
styleRef.current = style
}
const uniqueIds = Array.from(new Set(userIds))
styleRef.current.textContent = uniqueIds.map((userId) => {
const color = getUserColor(userId)
return `.${getSelectionClassName(userId)} { background-color: ${hexToRgba(color, SELECTION_ALPHA)}; }`
}).join('\n')
}, [])
useEffect(() => {
return () => {
if (styleRef.current) {
styleRef.current.remove()
styleRef.current = null
}
}
}, [])
const scheduleRecalc = useCallback(() => {
if (rafIdRef.current !== null)
return
rafIdRef.current = window.requestAnimationFrame(() => {
rafIdRef.current = null
if (!enabled || !fileId || !editor) {
dispatchRender({ type: 'clear' })
return
}
const model = editor.getModel()
if (!model) {
dispatchRender({ type: 'clear' })
return
}
const now = Date.now()
const positions: CursorOverlayItem[] = []
Object.values(cursorMapRef.current).forEach((cursor) => {
if (cursor.userId === myUserId)
return
if (now - cursor.timestamp > CURSOR_TTL_MS)
return
const maxOffset = model.getValueLength()
const endOffset = clampOffset(cursor.end, maxOffset)
const caretPosition = model.getPositionAt(endOffset)
const visible = editor.getScrolledVisiblePosition(caretPosition)
if (!visible)
return
const user = onlineUserMap[cursor.userId]
positions.push({
userId: cursor.userId,
x: visible.left,
y: visible.top,
height: visible.height || 20,
name: user?.username || cursor.userId.slice(-4),
color: getUserColor(cursor.userId),
})
})
dispatchRender({ type: 'set', positions })
})
}, [editor, enabled, fileId, myUserId, onlineUserMap])
useEffect(() => {
scheduleRecalc()
}, [scheduleRecalc, cursorMap, onlineUserMap])
useEffect(() => {
if (!enabled || !fileId || !editor)
return
const disposables = [
editor.onDidScrollChange(scheduleRecalc),
editor.onDidLayoutChange(scheduleRecalc),
editor.onDidChangeModelContent(scheduleRecalc),
]
return () => {
disposables.forEach(disposable => disposable.dispose())
}
}, [editor, enabled, fileId, scheduleRecalc])
useEffect(() => {
if (!editor) {
decorationCollectionRef.current = null
return
}
decorationCollectionRef.current = editor.createDecorationsCollection()
return () => {
decorationCollectionRef.current?.clear()
decorationCollectionRef.current = null
}
}, [editor])
useEffect(() => {
if (!editor) {
updateSelectionStyles([])
return
}
if (!enabled || !fileId) {
decorationCollectionRef.current?.clear()
updateSelectionStyles([])
return
}
const model = editor.getModel()
if (!model)
return
const now = Date.now()
const decorations: MonacoDecoration[] = []
const activeUserIds: string[] = []
Object.values(effectiveCursorMap).forEach((cursor) => {
if (cursor.userId === myUserId)
return
if (now - cursor.timestamp > CURSOR_TTL_MS)
return
if (cursor.start === cursor.end)
return
activeUserIds.push(cursor.userId)
decorations.push({
range: getSelectionRange(model, cursor.start, cursor.end),
options: {
inlineClassName: getSelectionClassName(cursor.userId),
},
})
})
updateSelectionStyles(activeUserIds)
decorationCollectionRef.current?.set(decorations)
}, [editor, enabled, fileId, effectiveCursorMap, myUserId, updateSelectionStyles])
useEffect(() => {
if (!enabled || !fileId || !editor)
return
const flushPending = () => {
const pending = pendingCursorRef.current
pendingCursorRef.current = null
if (!pending)
return
if (lastCursorRef.current
&& lastCursorRef.current.start === pending.start
&& lastCursorRef.current.end === pending.end) {
return
}
lastCursorRef.current = pending
skillCollaborationManager.emitCursorUpdate(fileId, pending)
}
const scheduleEmit = (cursor: { start: number, end: number }) => {
pendingCursorRef.current = cursor
if (throttleTimerRef.current !== null)
return
throttleTimerRef.current = window.setTimeout(() => {
throttleTimerRef.current = null
flushPending()
}, CURSOR_THROTTLE_MS)
}
const emitClear = () => {
if (throttleTimerRef.current !== null) {
window.clearTimeout(throttleTimerRef.current)
throttleTimerRef.current = null
}
pendingCursorRef.current = null
lastCursorRef.current = null
skillCollaborationManager.emitCursorUpdate(fileId, null)
}
const handleSelectionChange = () => {
const model = editor.getModel()
const selection = editor.getSelection()
if (!model || !selection)
return
const start = model.getOffsetAt(selection.getStartPosition())
const end = model.getOffsetAt(selection.getEndPosition())
scheduleEmit({ start, end })
}
const selectionDisposable = editor.onDidChangeCursorSelection(handleSelectionChange)
const focusDisposable = editor.onDidFocusEditorText(handleSelectionChange)
const blurDisposable = editor.onDidBlurEditorText(emitClear)
const blurWidgetDisposable = editor.onDidBlurEditorWidget(emitClear)
handleSelectionChange()
return () => {
selectionDisposable.dispose()
focusDisposable.dispose()
blurDisposable.dispose()
blurWidgetDisposable.dispose()
emitClear()
}
}, [editor, enabled, fileId])
const overlay = useMemo(() => {
if (!enabled || !fileId || renderState.positions.length === 0)
return null
return (
<>
{renderState.positions.map(position => (
<div
key={position.userId}
className="absolute"
style={{
left: position.x,
top: position.y,
}}
>
<div
className="absolute left-0 top-0 w-[2px]"
style={{
height: Math.max(position.height, 16),
backgroundColor: position.color,
}}
/>
<div
className="absolute -top-5 left-2 max-w-[160px] overflow-hidden text-ellipsis whitespace-nowrap rounded px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm"
style={{
backgroundColor: position.color,
}}
>
{position.name}
</div>
</div>
))}
</>
)
}, [enabled, fileId, renderState.positions])
return {
overlay,
}
}

View File

@@ -1,37 +1,69 @@
import type { OnMount } from '@monaco-editor/react'
import Editor from '@monaco-editor/react'
import * as React from 'react'
import Loading from '@/app/components/base/loading'
import { useSkillCodeCursors } from './code-editor/plugins/remote-cursors'
type CodeFileEditorProps = {
language: string
theme: string
value: string
onChange: (value: string | undefined) => void
onMount: (editor: any, monaco: any) => void
onMount: OnMount
fileId?: string | null
collaborationEnabled?: boolean
}
const CodeFileEditor = ({ language, theme, value, onChange, onMount }: CodeFileEditorProps) => {
const CodeFileEditor = ({
language,
theme,
value,
onChange,
onMount,
fileId,
collaborationEnabled,
}: CodeFileEditorProps) => {
const [editorInstance, setEditorInstance] = React.useState<Parameters<typeof onMount>[0] | null>(null)
const { overlay } = useSkillCodeCursors({
editor: editorInstance,
fileId: fileId ?? null,
enabled: Boolean(collaborationEnabled && fileId),
})
const handleMount = React.useCallback<OnMount>((editor, monaco) => {
setEditorInstance(editor)
onMount(editor, monaco)
}, [onMount])
return (
<Editor
language={language}
theme={theme}
value={value}
loading={<Loading type="area" />}
onChange={onChange}
options={{
minimap: { enabled: false },
lineNumbersMinChars: 3,
wordWrap: 'on',
unicodeHighlight: {
ambiguousCharacters: false,
},
stickyScroll: { enabled: false },
fontSize: 13,
lineHeight: 20,
padding: { top: 12, bottom: 12 },
}}
onMount={onMount}
/>
<div className="relative h-full w-full">
<Editor
language={language}
theme={theme}
value={value}
loading={<Loading type="area" />}
onChange={onChange}
options={{
minimap: { enabled: false },
lineNumbersMinChars: 3,
wordWrap: 'on',
unicodeHighlight: {
ambiguousCharacters: false,
},
stickyScroll: { enabled: false },
fontSize: 13,
lineHeight: 20,
padding: { top: 12, bottom: 12 },
}}
onMount={handleMount}
/>
{overlay
? (
<div className="pointer-events-none absolute inset-0 z-[2]">
{overlay}
</div>
)
: null}
</div>
)
}

View File

@@ -234,6 +234,8 @@ const FileContentPanel = () => {
value={currentContent}
onChange={handleCodeCollaborativeChange}
onMount={handleEditorDidMount}
fileId={fileTabId}
collaborationEnabled={canInitCollaboration}
/>
)
: null}

View File

@@ -3852,11 +3852,6 @@
"count": 4
}
},
"app/components/workflow/skill/editor/code-file-editor.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/workflow/store/workflow/debug/inspect-vars-slice.ts": {
"ts/no-explicit-any": {
"count": 2