diff --git a/web/app/components/workflow/collaboration/skills/skill-collaboration-manager.ts b/web/app/components/workflow/collaboration/skills/skill-collaboration-manager.ts index 2dc2ae9b44..89d8968953 100644 --- a/web/app/components/workflow/collaboration/skills/skill-collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/skills/skill-collaboration-manager.ts @@ -1,6 +1,7 @@ import type { Socket } from 'socket.io-client' import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration' import { LoroDoc } from 'loro-crdt' +import { EventEmitter } from '@/app/components/workflow/collaboration/core/event-emitter' import { emitWithAuthGuard, webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' type SkillUpdatePayload = { @@ -14,6 +15,12 @@ type SkillStatusPayload = { isLeader: boolean } +type SkillCursorPayload = { + file_id: string + start?: number | null + end?: number | null +} + type SkillDocEntry = { doc: LoroDoc text: ReturnType @@ -21,6 +28,15 @@ type SkillDocEntry = { suppressBroadcast: boolean } +type SkillCursorInfo = { + userId: string + start: number + end: number + timestamp: number +} + +type SkillCursorMap = Record + class SkillCollaborationManager { private appId: string | null = null private socket: Socket | null = null @@ -29,11 +45,18 @@ class SkillCollaborationManager { private syncHandlers = new Map void>>() private activeFileId: string | null = null private pendingResync = new Set() + private cursorByFile = new Map() + private cursorEmitter = new EventEmitter() private handleSkillUpdate = (payload: SkillUpdatePayload) => { if (!payload || !payload.file_id || !payload.update) return + if (payload.is_snapshot) { + this.replaceEntryWithSnapshot(payload.file_id, payload.update) + return + } + const entry = this.docs.get(payload.file_id) if (!entry) return @@ -57,6 +80,28 @@ class SkillCollaborationManager { if (!update || !update.type) return + if (update.type === 'skill_cursor') { + const data = update.data as SkillCursorPayload | undefined + const fileId = data?.file_id + if (!fileId || !update.userId) + return + + const start = typeof data?.start === 'number' ? data.start : null + const end = typeof data?.end === 'number' ? data.end : null + if (start === null || end === null || start < 0 || end < 0) { + this.updateCursor(fileId, update.userId, null) + return + } + + this.updateCursor(fileId, update.userId, { + userId: update.userId, + start, + end, + timestamp: update.timestamp, + }) + return + } + if (update.type === 'skill_resync_request') { const fileId = (update.data as { file_id?: string } | undefined)?.file_id if (!fileId || !this.isLeader(fileId)) @@ -92,6 +137,8 @@ class SkillCollaborationManager { this.syncHandlers.clear() this.activeFileId = null this.pendingResync.clear() + this.cursorByFile.clear() + this.cursorEmitter.removeAllListeners() } this.appId = appId @@ -132,12 +179,8 @@ class SkillCollaborationManager { if (!this.docs.has(fileId)) { const doc = new LoroDoc() const text = doc.getText('content') - const entry: SkillDocEntry = { - doc, - text, - subscribers: new Set(), - suppressBroadcast: true, - } + const entry = this.createEntry(fileId, doc, text) + entry.suppressBroadcast = true if (initialContent) text.update(initialContent) @@ -145,19 +188,6 @@ class SkillCollaborationManager { doc.commit() entry.suppressBroadcast = false - doc.subscribe((event: { by?: string }) => { - if (event.by === 'local') { - if (entry.suppressBroadcast) - return - const update = doc.export({ mode: 'update' }) - this.emitUpdate(fileId, update) - return - } - - const nextText = text.toString() - entry.subscribers.forEach(callback => callback(nextText, 'remote')) - }) - this.docs.set(fileId, entry) } @@ -216,6 +246,16 @@ class SkillCollaborationManager { } } + onCursorUpdate(fileId: string, callback: (cursors: SkillCursorMap) => void): () => void { + if (!fileId) + return () => {} + + const eventKey = this.getCursorEventKey(fileId) + const off = this.cursorEmitter.on(eventKey, callback) + callback({ ...(this.cursorByFile.get(fileId) || {}) }) + return off + } + isLeader(fileId: string): boolean { return this.leaderByFile.get(fileId) || false } @@ -228,6 +268,23 @@ class SkillCollaborationManager { this.emitSyncRequest(fileId) } + emitCursorUpdate(fileId: string, cursor: { start: number, end: number } | null): void { + if (!fileId || !this.socket || !this.socket.connected) + return + + const payload: SkillCursorPayload = { + file_id: fileId, + start: cursor?.start ?? null, + end: cursor?.end ?? null, + } + + emitWithAuthGuard(this.socket, 'collaboration_event', { + type: 'skill_cursor', + data: payload, + timestamp: Date.now(), + }) + } + setActiveFile(appId: string, fileId: string, active: boolean): void { if (!appId || !fileId) return @@ -293,6 +350,79 @@ class SkillCollaborationManager { timestamp: Date.now(), }) } + + private getCursorEventKey(fileId: string): string { + return `skill_cursor:${fileId}` + } + + private updateCursor(fileId: string, userId: string, cursor: SkillCursorInfo | null): void { + const current = this.cursorByFile.get(fileId) || {} + if (!cursor) { + if (!current[userId]) + return + delete current[userId] + this.cursorByFile.set(fileId, current) + this.cursorEmitter.emit(this.getCursorEventKey(fileId), { ...current }) + return + } + + current[userId] = cursor + this.cursorByFile.set(fileId, current) + this.cursorEmitter.emit(this.getCursorEventKey(fileId), { ...current }) + } + + private subscribeDoc(fileId: string, entry: SkillDocEntry) { + entry.doc.subscribe((event: { by?: string }) => { + if (event.by === 'local') { + if (entry.suppressBroadcast) + return + const update = entry.doc.export({ mode: 'update' }) + this.emitUpdate(fileId, update) + return + } + + const nextText = entry.text.toString() + entry.subscribers.forEach(callback => callback(nextText, 'remote')) + }) + } + + private createEntry(fileId: string, doc: LoroDoc, text: ReturnType) { + const entry: SkillDocEntry = { + doc, + text, + subscribers: new Set(), + suppressBroadcast: false, + } + + this.subscribeDoc(fileId, entry) + return entry + } + + private replaceEntryWithSnapshot(fileId: string, snapshot: Uint8Array) { + const existing = this.docs.get(fileId) + const subscribers = existing?.subscribers ?? new Set<(text: string, source: 'remote') => void>() + const doc = new LoroDoc() + try { + doc.import(new Uint8Array(snapshot)) + } + catch (error) { + console.error('Failed to import skill snapshot:', error) + return + } + + const text = doc.getText('content') + const entry: SkillDocEntry = { + doc, + text, + subscribers, + suppressBroadcast: false, + } + this.subscribeDoc(fileId, entry) + this.docs.set(fileId, entry) + + const nextText = text.toString() + entry.subscribers.forEach(callback => callback(nextText, 'remote')) + } } export const skillCollaborationManager = new SkillCollaborationManager() diff --git a/web/app/components/workflow/collaboration/types/collaboration.ts b/web/app/components/workflow/collaboration/types/collaboration.ts index 2809e54d52..48e63eb08e 100644 --- a/web/app/components/workflow/collaboration/types/collaboration.ts +++ b/web/app/components/workflow/collaboration/types/collaboration.ts @@ -64,6 +64,7 @@ export type CollaborationEventType | 'app_publish_update' | 'graph_view_active' | 'skill_file_active' + | 'skill_cursor' | 'skill_sync_request' | 'skill_resync_request' | 'graph_resync_request' diff --git a/web/app/components/workflow/skill/editor/markdown-file-editor.tsx b/web/app/components/workflow/skill/editor/markdown-file-editor.tsx index 6181725240..09f7c1673d 100644 --- a/web/app/components/workflow/skill/editor/markdown-file-editor.tsx +++ b/web/app/components/workflow/skill/editor/markdown-file-editor.tsx @@ -7,9 +7,15 @@ type MarkdownFileEditorProps = { instanceId?: string value: string onChange: (value: string) => void + collaborationEnabled?: boolean } -const MarkdownFileEditor: FC = ({ instanceId, value, onChange }) => { +const MarkdownFileEditor: FC = ({ + instanceId, + value, + onChange, + collaborationEnabled, +}) => { const { t } = useTranslation() const handleChange = React.useCallback((val: string) => { if (val !== value) { @@ -23,6 +29,7 @@ const MarkdownFileEditor: FC = ({ instanceId, value, on instanceId={instanceId} value={value} onChange={handleChange} + collaborationEnabled={collaborationEnabled} showLineNumbers className="h-full" wrapperClassName="h-full" diff --git a/web/app/components/workflow/skill/editor/skill-editor/index.tsx b/web/app/components/workflow/skill/editor/skill-editor/index.tsx index d4107cc38e..6f24601a5e 100644 --- a/web/app/components/workflow/skill/editor/skill-editor/index.tsx +++ b/web/app/components/workflow/skill/editor/skill-editor/index.tsx @@ -24,6 +24,7 @@ import styles from './line-numbers.module.css' import FilePickerBlock from './plugins/file-picker-block' import { FileReferenceNode } from './plugins/file-reference-block/node' import FileReferenceReplacementBlock from './plugins/file-reference-block/replacement-block' +import { LocalCursorPlugin, SkillRemoteCursors } from './plugins/remote-cursors' import { ToolBlock, ToolBlockNode, @@ -44,6 +45,7 @@ export type SkillEditorProps = { style?: React.CSSProperties value?: string editable?: boolean + collaborationEnabled?: boolean onChange?: (text: string) => void onBlur?: () => void onFocus?: () => void @@ -61,6 +63,7 @@ const SkillEditor: FC = ({ style, value, editable = true, + collaborationEnabled, onChange, onBlur, onFocus, @@ -135,6 +138,8 @@ const SkillEditor: FC = ({ + + diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/remote-cursors/index.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/remote-cursors/index.tsx new file mode 100644 index 0000000000..77d2571525 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/remote-cursors/index.tsx @@ -0,0 +1,656 @@ +'use client' +import type { RangeSelection, TextNode } from 'lexical' +import type { FC } from 'react' +import type { OnlineUser } from '@/app/components/workflow/collaboration/types' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { + $getRoot, + $getSelection, + $isElementNode, + $isRangeSelection, + $isTextNode, + BLUR_COMMAND, + COMMAND_PRIORITY_LOW, + SELECTION_CHANGE_COMMAND, +} from 'lexical' +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 CURSOR_RECALC_INTERVAL_MS = 4000 + +type SkillCursorInfo = { + userId: string + start: number + end: number + timestamp: number +} + +type SkillCursorMap = Record + +type TextOffset = { + node: TextNode + start: number + end: number +} + +type TextOffsetMap = { + textNodes: TextOffset[] + newlinePositions: Set + elementEntries: Array<{ key: string, start: number, end: number, hasText: boolean }> +} + +type CursorPosition = { + userId: string + x: number + y: number + height: number +} + +type SelectionRect = { + userId: string + x: number + y: number + width: number + height: number +} + +type CursorRenderState = { + positions: CursorPosition[] + selectionRects: SelectionRect[] +} + +type CursorRenderAction + = | { type: 'set', positions: CursorPosition[], selectionRects: SelectionRect[] } + | { type: 'clear' } + +const cursorRenderReducer = (_state: CursorRenderState, action: CursorRenderAction): CursorRenderState => { + if (action.type === 'clear') + return { positions: [], selectionRects: [] } + return { positions: action.positions, selectionRects: action.selectionRects } +} + +const buildTextOffsetMap = (): TextOffsetMap => { + const root = $getRoot() + const textNodes: TextOffset[] = [] + const newlinePositions = new Set() + const elementEntries: Array<{ key: string, start: number, end: number, hasText: boolean }> = [] + let cursor = 0 + const children = root.getChildren() + + children.forEach((child, index) => { + const startOffset = cursor + const childTextNodes = $isElementNode(child) ? child.getAllTextNodes() : ($isTextNode(child) ? [child] : []) + let hasText = false + childTextNodes.forEach((node) => { + const text = node.getTextContent() + if (text.length > 0) + hasText = true + const start = cursor + const end = cursor + text.length + textNodes.push({ node, start, end }) + cursor = end + }) + elementEntries.push({ key: child.getKey(), start: startOffset, end: cursor, hasText }) + + if (index < children.length - 1) { + newlinePositions.add(cursor) + cursor += 1 + } + }) + + return { textNodes, newlinePositions, elementEntries } +} + +const getPointOffset = (map: TextOffsetMap, point: RangeSelection['anchor']): number | null => { + for (const item of map.textNodes) { + if (item.node.getKey() === point.key) { + const length = item.node.getTextContent().length + const clamped = Math.max(0, Math.min(point.offset, length)) + return item.start + clamped + } + } + const elementEntry = map.elementEntries.find(entry => entry.key === point.key) + if (elementEntry) { + if (point.offset <= 0) + return elementEntry.start + return elementEntry.end + } + return null +} + +const getSelectionOffsets = ( + selection: RangeSelection, + map: TextOffsetMap, +): { start: number, end: number } | null => { + const anchorOffset = getPointOffset(map, selection.anchor) + const focusOffset = getPointOffset(map, selection.focus) + if (anchorOffset === null || focusOffset === null) + return null + + return { + start: Math.min(anchorOffset, focusOffset), + end: Math.max(anchorOffset, focusOffset), + } +} + +const findTextNodeAtOffset = (map: TextOffsetMap, offset: number): { node: TextNode, offset: number } | null => { + if (map.textNodes.length === 0) + return null + + if (map.newlinePositions.has(offset)) { + for (let i = map.textNodes.length - 1; i >= 0; i--) { + const item = map.textNodes[i] + if (item.end <= offset) + return { node: item.node, offset: item.node.getTextContent().length } + } + } + + for (const item of map.textNodes) { + if (offset <= item.end) { + const localOffset = Math.max(0, Math.min(offset - item.start, item.node.getTextContent().length)) + return { node: item.node, offset: localOffset } + } + } + + const last = map.textNodes[map.textNodes.length - 1] + return { node: last.node, offset: last.node.getTextContent().length } +} + +const findEmptyElementAtOffset = ( + map: TextOffsetMap, + offset: number, +): { key: string } | null => { + for (const entry of map.elementEntries) { + if (!entry.hasText && offset >= entry.start && offset <= entry.end) + return { key: entry.key } + } + return null +} + +const getCursorPosition = ( + map: TextOffsetMap, + offset: number, + rootElement: HTMLElement, + getElementByKey: (key: string) => HTMLElement | null, +): { x: number, y: number, height: number } | null => { + const emptyEntry = map.newlinePositions.has(offset) + ? findEmptyElementAtOffset(map, offset) + : null + const target = emptyEntry ? null : findTextNodeAtOffset(map, offset) + if (!target) { + const fallbackEntry = emptyEntry || findEmptyElementAtOffset(map, offset) + if (!fallbackEntry) + return null + + const domElement = getElementByKey(fallbackEntry.key) + if (!domElement) + return null + + const rect = domElement.getBoundingClientRect() + const rootRect = rootElement.getBoundingClientRect() + const lineHeight = Number.parseFloat(window.getComputedStyle(rootElement).lineHeight || '') || 16 + return { + x: rect.left - rootRect.left + rootElement.scrollLeft, + y: rect.top - rootRect.top + rootElement.scrollTop, + height: rect.height || lineHeight, + } + } + + const domElement = getElementByKey(target.node.getKey()) + if (!domElement) + return null + + const textNode = domElement.firstChild + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) + return null + + const textLength = textNode.textContent?.length ?? 0 + const clampedOffset = Math.max(0, Math.min(target.offset, textLength)) + const range = document.createRange() + range.setStart(textNode, clampedOffset) + range.setEnd(textNode, clampedOffset) + + const rect = range.getBoundingClientRect() + const rootRect = rootElement.getBoundingClientRect() + const lineHeight = Number.parseFloat(window.getComputedStyle(rootElement).lineHeight || '') || 16 + const height = rect.height || lineHeight + + return { + x: rect.left - rootRect.left + rootElement.scrollLeft, + y: rect.top - rootRect.top + rootElement.scrollTop, + height, + } +} + +const hexToRgba = (hex: string, alpha: number): string => { + const normalized = hex.replace('#', '') + if (normalized.length !== 6) + return hex + const r = Number.parseInt(normalized.slice(0, 2), 16) + const g = Number.parseInt(normalized.slice(2, 4), 16) + const b = Number.parseInt(normalized.slice(4, 6), 16) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +const getSelectionRects = ( + map: TextOffsetMap, + start: number, + end: number, + rootElement: HTMLElement, + getElementByKey: (key: string) => HTMLElement | null, +): Array> => { + if (start === end) + return [] + + const normalizedStart = Math.max(0, Math.min(start, end)) + const normalizedEnd = Math.max(normalizedStart, Math.max(start, end)) + const rootRect = rootElement.getBoundingClientRect() + const rects: Array> = [] + + for (const item of map.textNodes) { + if (item.end < normalizedStart) + continue + if (item.start > normalizedEnd) + break + + const localStart = Math.max(normalizedStart, item.start) - item.start + const localEnd = Math.min(normalizedEnd, item.end) - item.start + if (localEnd <= localStart) + continue + + const domElement = getElementByKey(item.node.getKey()) + if (!domElement) + continue + + const textNode = domElement.firstChild + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) + continue + + const textLength = textNode.textContent?.length ?? 0 + const startOffset = Math.max(0, Math.min(localStart, textLength)) + const endOffset = Math.max(0, Math.min(localEnd, textLength)) + if (endOffset <= startOffset) + continue + + const range = document.createRange() + range.setStart(textNode, startOffset) + range.setEnd(textNode, endOffset) + + Array.from(range.getClientRects()).forEach((rect) => { + if (rect.width === 0 || rect.height === 0) + return + rects.push({ + x: rect.left - rootRect.left + rootElement.scrollLeft, + y: rect.top - rootRect.top + rootElement.scrollTop, + width: rect.width, + height: rect.height, + }) + }) + } + + for (const entry of map.elementEntries) { + if (entry.hasText) + continue + if (entry.end < normalizedStart || entry.start > normalizedEnd) + continue + + const domElement = getElementByKey(entry.key) + if (!domElement) + continue + const rect = domElement.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) + continue + rects.push({ + x: rect.left - rootRect.left + rootElement.scrollLeft, + y: rect.top - rootRect.top + rootElement.scrollTop, + width: rect.width, + height: rect.height, + }) + } + + return rects +} + +export const LocalCursorPlugin: FC<{ + fileId?: string + enabled?: boolean +}> = ({ fileId, enabled }) => { + const [editor] = useLexicalComposerContext() + const lastEmittedCursorRef = useRef<{ start: number, end: number } | null>(null) + const lastEmitRef = useRef(0) + const pendingCursorRef = useRef<{ start: number, end: number } | null | undefined>(undefined) + const throttleTimerRef = useRef(null) + + const emitCursor = useCallback((cursor: { start: number, end: number } | null) => { + if (!enabled || !fileId) + return + skillCollaborationManager.emitCursorUpdate(fileId, cursor) + }, [enabled, fileId]) + + const isSameCursor = useCallback(( + a: { start: number, end: number } | null, + b: { start: number, end: number } | null, + ) => { + if (a === b) + return true + if (!a || !b) + return false + return a.start === b.start && a.end === b.end + }, []) + + const flushPending = useCallback(() => { + const pending = pendingCursorRef.current + pendingCursorRef.current = undefined + if (pending === undefined) + return + + if (!isSameCursor(pending, lastEmittedCursorRef.current)) { + emitCursor(pending) + lastEmittedCursorRef.current = pending + lastEmitRef.current = Date.now() + } + }, [emitCursor, isSameCursor]) + + const handleSelectionChange = useCallback(() => { + if (!enabled || !fileId) + return + + editor.getEditorState().read(() => { + const now = Date.now() + const selection = $getSelection() + let nextCursor: { start: number, end: number } | null = null + if (!$isRangeSelection(selection)) { + nextCursor = null + } + else { + const map = buildTextOffsetMap() + const offsets = getSelectionOffsets(selection, map) + if (!offsets) + return + + nextCursor = offsets + } + + if (isSameCursor(nextCursor, lastEmittedCursorRef.current)) + return + + const elapsed = now - lastEmitRef.current + if (elapsed >= CURSOR_THROTTLE_MS) { + if (throttleTimerRef.current !== null) { + window.clearTimeout(throttleTimerRef.current) + throttleTimerRef.current = null + } + pendingCursorRef.current = undefined + emitCursor(nextCursor) + lastEmittedCursorRef.current = nextCursor + lastEmitRef.current = now + return + } + + pendingCursorRef.current = nextCursor + if (throttleTimerRef.current === null) { + throttleTimerRef.current = window.setTimeout(() => { + throttleTimerRef.current = null + flushPending() + }, CURSOR_THROTTLE_MS - elapsed) + } + }) + }, [editor, emitCursor, enabled, fileId, flushPending, isSameCursor]) + + useEffect(() => { + if (!enabled || !fileId) + return + + const unregisterSelection = editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + handleSelectionChange() + return false + }, + COMMAND_PRIORITY_LOW, + ) + + const unregisterBlur = editor.registerCommand( + BLUR_COMMAND, + () => { + if (throttleTimerRef.current !== null) { + window.clearTimeout(throttleTimerRef.current) + throttleTimerRef.current = null + } + pendingCursorRef.current = undefined + emitCursor(null) + lastEmittedCursorRef.current = null + lastEmitRef.current = Date.now() + return false + }, + COMMAND_PRIORITY_LOW, + ) + + const unregisterUpdate = editor.registerUpdateListener(() => { + handleSelectionChange() + }) + + return () => { + unregisterSelection() + unregisterBlur() + unregisterUpdate() + if (throttleTimerRef.current !== null) { + window.clearTimeout(throttleTimerRef.current) + throttleTimerRef.current = null + } + } + }, [editor, emitCursor, enabled, fileId, flushPending, handleSelectionChange]) + + useEffect(() => { + return () => { + if (throttleTimerRef.current !== null) { + window.clearTimeout(throttleTimerRef.current) + throttleTimerRef.current = null + } + pendingCursorRef.current = undefined + if (fileId) + skillCollaborationManager.emitCursorUpdate(fileId, null) + } + }, [fileId]) + + return null +} + +export const SkillRemoteCursors: FC<{ + fileId?: string + enabled?: boolean +}> = ({ fileId, enabled }) => { + const [editor] = useLexicalComposerContext() + const { userProfile } = useAppContext() + const myUserId = userProfile?.id || null + const [cursorMap, setCursorMap] = useState({}) + const [onlineUsers, setOnlineUsers] = useState([]) + const [renderState, dispatchRender] = useReducer(cursorRenderReducer, { + positions: [], + selectionRects: [], + }) + const cursorMapRef = useRef({}) + const rafIdRef = useRef(null) + const effectiveCursorMap = useMemo(() => (enabled && fileId ? cursorMap : {}), [cursorMap, enabled, fileId]) + + useEffect(() => { + if (!enabled || !fileId) + return + + return skillCollaborationManager.onCursorUpdate(fileId, (nextCursors) => { + setCursorMap(nextCursors) + }) + }, [enabled, fileId]) + + useEffect(() => { + cursorMapRef.current = effectiveCursorMap + }, [effectiveCursorMap]) + + useEffect(() => { + return collaborationManager.onOnlineUsersUpdate(setOnlineUsers) + }, []) + + const onlineUserMap = useMemo(() => { + return onlineUsers.reduce>((acc, user) => { + acc[user.user_id] = user + return acc + }, {}) + }, [onlineUsers]) + + const scheduleRecalc = useCallback(() => { + if (rafIdRef.current !== null) + return + rafIdRef.current = window.requestAnimationFrame(() => { + rafIdRef.current = null + if (!enabled || !fileId) { + dispatchRender({ type: 'clear' }) + return + } + + const rootElement = editor.getRootElement() + if (!rootElement) { + dispatchRender({ type: 'clear' }) + return + } + + editor.getEditorState().read(() => { + const map = buildTextOffsetMap() + const now = Date.now() + const next: CursorPosition[] = [] + const nextSelection: SelectionRect[] = [] + if (map.textNodes.length === 0) { + const lineHeight = Number.parseFloat(window.getComputedStyle(rootElement).lineHeight || '') || 16 + Object.entries(cursorMapRef.current).forEach(([userId, cursor]) => { + if (now - cursor.timestamp > CURSOR_TTL_MS) + return + + if (userId !== myUserId) { + next.push({ + userId, + x: rootElement.scrollLeft, + y: rootElement.scrollTop, + height: lineHeight, + }) + } + }) + + dispatchRender({ type: 'set', positions: next, selectionRects: nextSelection }) + return + } + + Object.entries(cursorMapRef.current).forEach(([userId, cursor]) => { + if (now - cursor.timestamp > CURSOR_TTL_MS) + return + + const caretOffset = cursor.end + if (userId !== myUserId) { + const pos = getCursorPosition(map, caretOffset, rootElement, key => editor.getElementByKey(key)) + if (pos) { + next.push({ + userId, + ...pos, + }) + } + } + + if (cursor.start !== cursor.end) { + const rects = getSelectionRects( + map, + cursor.start, + cursor.end, + rootElement, + key => editor.getElementByKey(key), + ) + rects.forEach(rect => nextSelection.push({ userId, ...rect })) + } + }) + + dispatchRender({ type: 'set', positions: next, selectionRects: nextSelection }) + }) + }) + }, [editor, enabled, fileId, myUserId]) + + useEffect(() => { + scheduleRecalc() + }, [scheduleRecalc, cursorMap, onlineUserMap]) + + useEffect(() => { + if (!enabled || !fileId) + return + + const unregister = editor.registerUpdateListener(() => { + scheduleRecalc() + }) + + const timer = window.setInterval(scheduleRecalc, CURSOR_RECALC_INTERVAL_MS) + + return () => { + unregister() + window.clearInterval(timer) + } + }, [editor, enabled, fileId, scheduleRecalc]) + + if (!enabled || !fileId || renderState.positions.length === 0) + return null + + return ( +
+ {renderState.selectionRects.map((rect) => { + if (rect.userId === myUserId) + return null + const color = getUserColor(rect.userId) + const key = `${rect.userId}-${Math.round(rect.x)}-${Math.round(rect.y)}-${Math.round(rect.width)}-${Math.round(rect.height)}` + return ( +
+ ) + })} + {renderState.positions.map((cursor) => { + const user = onlineUserMap[cursor.userId] + const name = user?.username || cursor.userId.slice(-4) + const color = getUserColor(cursor.userId) + + return ( +
+
+
+ {name} +
+
+ ) + })} +
+ ) +} diff --git a/web/app/components/workflow/skill/file-content-panel.tsx b/web/app/components/workflow/skill/file-content-panel.tsx index 97d1a74e2c..db86e9739f 100644 --- a/web/app/components/workflow/skill/file-content-panel.tsx +++ b/web/app/components/workflow/skill/file-content-panel.tsx @@ -211,6 +211,7 @@ const FileContentPanel: FC = () => { instanceId={fileTabId || undefined} value={currentContent} onChange={handleCollaborativeChange} + collaborationEnabled={canInitCollaboration} /> ) : null}