import { Editor, Range } from 'slate'; import { Awareness } from 'y-protocols/awareness'; import * as Y from 'yjs'; import { RelativeRange } from '../model/types'; import { slateRangeToRelativeRange } from '../utils/position'; import { YjsEditor } from './withYjs'; export type CursorStateChangeEvent = { added: number[]; updated: number[]; removed: number[]; }; export type RemoteCursorChangeEventListener = ( event: CursorStateChangeEvent ) => void; const CURSOR_CHANGE_EVENT_LISTENERS: WeakMap< Editor, Set > = new WeakMap(); export type CursorState< TCursorData extends Record = Record > = { relativeSelection: RelativeRange | null; data?: TCursorData; clientId: number; }; export type CursorEditor< TCursorData extends Record = Record > = YjsEditor & { awareness: Awareness; cursorDataField: string; selectionStateField: string; sendCursorPosition: (range: Range | null) => void; sendCursorData: (data: TCursorData) => void; }; export const CursorEditor = { isCursorEditor(value: unknown): value is CursorEditor { return ( YjsEditor.isYjsEditor(value) && (value as CursorEditor).awareness && typeof (value as CursorEditor).cursorDataField === 'string' && typeof (value as CursorEditor).selectionStateField === 'string' && typeof (value as CursorEditor).sendCursorPosition === 'function' && typeof (value as CursorEditor).sendCursorData === 'function' ); }, sendCursorPosition>( editor: CursorEditor, range: Range | null = editor.selection ) { editor.sendCursorPosition(range); }, sendCursorData>( editor: CursorEditor, data: TCursorData ) { editor.sendCursorData(data); }, on>( editor: CursorEditor, event: 'change', handler: RemoteCursorChangeEventListener ) { if (event !== 'change') { return; } const listeners = CURSOR_CHANGE_EVENT_LISTENERS.get(editor) ?? new Set(); listeners.add(handler); CURSOR_CHANGE_EVENT_LISTENERS.set(editor, listeners); }, off>( editor: CursorEditor, event: 'change', listener: RemoteCursorChangeEventListener ) { if (event !== 'change') { return; } const listeners = CURSOR_CHANGE_EVENT_LISTENERS.get(editor); if (listeners) { listeners.delete(listener); } }, cursorState>( editor: CursorEditor, clientId: number ): CursorState | null { if ( clientId === editor.awareness.clientID || !YjsEditor.connected(editor) ) { return null; } const state = editor.awareness.getStates().get(clientId); if (!state) { return null; } return { relativeSelection: state[editor.selectionStateField] ?? null, data: state[editor.cursorDataField], clientId, }; }, cursorStates>( editor: CursorEditor ): Record> { if (!YjsEditor.connected(editor)) { return {}; } return Object.fromEntries( Array.from(editor.awareness.getStates().entries(), ([id, state]) => { // Ignore own state if (id === editor.awareness.clientID || !state) { return null; } return [ id, { relativeSelection: state[editor.selectionStateField], data: state[editor.cursorDataField], }, ]; }).filter(Array.isArray) ); }, }; export type WithCursorsOptions< TCursorData extends Record = Record > = { // Local state field used to store the user selection cursorStateField?: string; // Local state field used to store data attached to the local client cursorDataField?: string; data?: TCursorData; autoSend?: boolean; }; export function withCursors< TCursorData extends Record, TEditor extends YjsEditor >( editor: TEditor, awareness: Awareness, { cursorStateField: selectionStateField = 'selection', cursorDataField = 'data', autoSend = true, data, }: WithCursorsOptions = {} ): TEditor & CursorEditor { const e = editor as TEditor & CursorEditor; e.awareness = awareness; e.cursorDataField = cursorDataField; e.selectionStateField = selectionStateField; e.sendCursorData = (cursorData: TCursorData) => { e.awareness.setLocalStateField(e.cursorDataField, cursorData); }; e.sendCursorPosition = (range) => { const localState = e.awareness.getLocalState(); const currentRange = localState?.[selectionStateField]; if (!range) { if (currentRange) { e.awareness.setLocalStateField(e.selectionStateField, null); } return; } const { anchor, focus } = slateRangeToRelativeRange(e.sharedRoot, e, range); if ( !currentRange || !Y.compareRelativePositions(anchor, currentRange) || !Y.compareRelativePositions(focus, currentRange) ) { e.awareness.setLocalStateField(e.selectionStateField, { anchor, focus }); } }; const awarenessChangeListener: RemoteCursorChangeEventListener = (yEvent) => { const listeners = CURSOR_CHANGE_EVENT_LISTENERS.get(e); if (!listeners) { return; } const localId = e.awareness.clientID; const event = { added: yEvent.added.filter((id) => id !== localId), removed: yEvent.removed.filter((id) => id !== localId), updated: yEvent.updated.filter((id) => id !== localId), }; if ( event.added.length > 0 || event.removed.length > 0 || event.updated.length > 0 ) { listeners.forEach((listener) => listener(event)); } }; const { connect, disconnect } = e; e.connect = () => { connect(); e.awareness.on('change', awarenessChangeListener); awarenessChangeListener({ removed: [], added: Array.from(e.awareness.getStates().keys()), updated: [], }); if (autoSend) { if (data) { CursorEditor.sendCursorData(e, data); } const { onChange } = e; e.onChange = () => { onChange(); if (YjsEditor.connected(e)) { CursorEditor.sendCursorPosition(e); } }; } }; e.disconnect = () => { e.awareness.off('change', awarenessChangeListener); awarenessChangeListener({ removed: Array.from(e.awareness.getStates().keys()), added: [], updated: [], }); disconnect(); }; return e; }