mirror of
https://github.com/langgenius/dify.git
synced 2026-03-16 12:27:42 +08:00
feat: skill markdown cursor pos sync
This commit is contained in:
@ -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<LoroDoc['getText']>
|
||||
@ -21,6 +28,15 @@ type SkillDocEntry = {
|
||||
suppressBroadcast: boolean
|
||||
}
|
||||
|
||||
type SkillCursorInfo = {
|
||||
userId: string
|
||||
start: number
|
||||
end: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
type SkillCursorMap = Record<string, SkillCursorInfo>
|
||||
|
||||
class SkillCollaborationManager {
|
||||
private appId: string | null = null
|
||||
private socket: Socket | null = null
|
||||
@ -29,11 +45,18 @@ class SkillCollaborationManager {
|
||||
private syncHandlers = new Map<string, Set<() => void>>()
|
||||
private activeFileId: string | null = null
|
||||
private pendingResync = new Set<string>()
|
||||
private cursorByFile = new Map<string, SkillCursorMap>()
|
||||
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<LoroDoc['getText']>) {
|
||||
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()
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -7,9 +7,15 @@ type MarkdownFileEditorProps = {
|
||||
instanceId?: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
collaborationEnabled?: boolean
|
||||
}
|
||||
|
||||
const MarkdownFileEditor: FC<MarkdownFileEditorProps> = ({ instanceId, value, onChange }) => {
|
||||
const MarkdownFileEditor: FC<MarkdownFileEditorProps> = ({
|
||||
instanceId,
|
||||
value,
|
||||
onChange,
|
||||
collaborationEnabled,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const handleChange = React.useCallback((val: string) => {
|
||||
if (val !== value) {
|
||||
@ -23,6 +29,7 @@ const MarkdownFileEditor: FC<MarkdownFileEditorProps> = ({ instanceId, value, on
|
||||
instanceId={instanceId}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
collaborationEnabled={collaborationEnabled}
|
||||
showLineNumbers
|
||||
className="h-full"
|
||||
wrapperClassName="h-full"
|
||||
|
||||
@ -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<SkillEditorProps> = ({
|
||||
style,
|
||||
value,
|
||||
editable = true,
|
||||
collaborationEnabled,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
@ -135,6 +138,8 @@ const SkillEditor: FC<SkillEditorProps> = ({
|
||||
<OnChangePlugin onChange={handleEditorChange} />
|
||||
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
|
||||
<UpdateBlock instanceId={instanceId} />
|
||||
<LocalCursorPlugin fileId={instanceId} enabled={collaborationEnabled} />
|
||||
<SkillRemoteCursors fileId={instanceId} enabled={collaborationEnabled} />
|
||||
<HistoryPlugin />
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
|
||||
@ -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<string, SkillCursorInfo>
|
||||
|
||||
type TextOffset = {
|
||||
node: TextNode
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
type TextOffsetMap = {
|
||||
textNodes: TextOffset[]
|
||||
newlinePositions: Set<number>
|
||||
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<number>()
|
||||
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<Omit<SelectionRect, 'userId'>> => {
|
||||
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<Omit<SelectionRect, 'userId'>> = []
|
||||
|
||||
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<number | null>(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<SkillCursorMap>({})
|
||||
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([])
|
||||
const [renderState, dispatchRender] = useReducer(cursorRenderReducer, {
|
||||
positions: [],
|
||||
selectionRects: [],
|
||||
})
|
||||
const cursorMapRef = useRef<SkillCursorMap>({})
|
||||
const rafIdRef = useRef<number | null>(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<Record<string, OnlineUser>>((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 (
|
||||
<div className="pointer-events-none absolute inset-0 z-[9]">
|
||||
{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 (
|
||||
<div
|
||||
key={key}
|
||||
className="absolute rounded-[2px]"
|
||||
style={{
|
||||
left: rect.x,
|
||||
top: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
backgroundColor: hexToRgba(color, 0.2),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{renderState.positions.map((cursor) => {
|
||||
const user = onlineUserMap[cursor.userId]
|
||||
const name = user?.username || cursor.userId.slice(-4)
|
||||
const color = getUserColor(cursor.userId)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cursor.userId}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: cursor.x,
|
||||
top: cursor.y,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-[2px]"
|
||||
style={{
|
||||
height: Math.max(cursor.height, 16),
|
||||
backgroundColor: 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: color,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -211,6 +211,7 @@ const FileContentPanel: FC = () => {
|
||||
instanceId={fileTabId || undefined}
|
||||
value={currentContent}
|
||||
onChange={handleCollaborativeChange}
|
||||
collaborationEnabled={canInitCollaboration}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
Reference in New Issue
Block a user