UNPKG

vzcode

Version:
320 lines (292 loc) 10.8 kB
import { ViewPlugin, EditorView, WidgetType, Decoration, } from '@codemirror/view'; import { Annotation, RangeSet } from '@codemirror/state'; import { assignUserColor } from '../presenceColor'; import { VizFileId } from '@vizhub/viz-types'; import { Presence, PresenceId, TabState, Username, } from '../../types'; const debug = false; // export let enableAutoFollow = false; // export const toggleAutoFollowButton = () => { // enableAutoFollow = !enableAutoFollow; // }; // Deals with receiving the broadcasted presence cursor locations // from other clients and displaying them. // // Inspired by // * https://github.com/yjs/y-codemirror.next/blob/main/src/y-remote-selections.js // * https://codemirror.net/examples/decoration/ // * https://github.com/share/sharedb/blob/master/examples/rich-text-presence/client.js // * https://share.github.io/sharedb/presence export const json1PresenceDisplay = ({ path, docPresence, enableAutoFollowRef, openTab, }: { path: Array<string>; docPresence: any; enableAutoFollowRef: React.MutableRefObject<boolean>; openTab: (tabState: TabState) => void; }) => [ ViewPlugin.fromClass( class { // The decorations to display. // This is a RangeSet of Decoration objects. // See https://codemirror.net/6/docs/ref/#view.Decoration decorations: RangeSet<Decoration>; //Added variable for cursor position cursorPosition = {}; constructor(view: EditorView) { // Initialize decorations to empty array so CodeMirror doesn't crash. this.decorations = RangeSet.of([]); // Mutable state local to this closure representing aggregated presence. // * Keys are presence ids // * Values are presence objects as defined by ot-json1-presence const presenceState: Record<PresenceId, Presence> = {}; // Add the scroll event listener //This runs for the arrow key scrolling, it should result in the users scrolling to eachother's location. // view.dom.addEventListener('scroll', () => { // this.scrollToCursor(view); // }); // Receive remote presence changes. docPresence.on( 'receive', (id: PresenceId, presence: Presence) => { if (debug) { console.log( `Received presence for id ${id}`, presence, ); // Debug statement } // If presence === null, the user has disconnected / exited if (!presence) { delete presenceState[id]; return; } // Check if the presence is for the current file or not. const isPresenceInCurrentFile = pathMatches( path, presence, ); // If the presence is in the current file, update the presence state. if (isPresenceInCurrentFile) { presenceState[id] = presence; } else if (presence) { // Otherwise, delete the presence state. delete presenceState[id]; // If auto-follow is enabled, and the presence is NOT // in the current file, then open the tab of the other user. if (enableAutoFollowRef.current) { openTab({ fileId: presence.start[1] as VizFileId, isTransient: true, }); } } // Update decorations to reflect new presence state. // TODO consider mutating this rather than recomputing it on each change. const presenceDecorations = []; // Object.keys(presenceState).map((id) => { for (const id of Object.keys(presenceState)) { const presence: Presence = presenceState[id]; const { start, end } = presence; const from = +start[start.length - 1]; const to = +end[end.length - 1]; const userColor = assignUserColor( presence.username, ); const { username } = presence; //console.log("File User Color:" + assignUserColor(presence.username)); presenceDecorations.push({ from, to: from, value: Decoration.widget({ side: -1, block: false, widget: new PresenceWidget( // TODO see if we can figure out why // updateDOM was not being called when passing // the presence id as the id // id, '' + Math.random(), userColor, username, ), }), }); // This is `true` when the presence is a cursor, // with no selection. if (from !== to) { // This is the case when the presence is a selection. presenceDecorations.push({ from, to, value: Decoration.mark({ class: 'cm-json1-presence', attributes: { style: ` background-color: rgba(${userColor}, 0.75); mix-blend-mode: luminosity; `, }, }), }); } if (view.state.doc.length >= from) { // Ensure position is valid this.cursorPosition[id] = from; // Store the cursor position, important to run if we cant get the regular scroll to work // console.log(`Stored cursor position for id ${id}: ${from}`); // Debug statement } else { // console.warn(`Invalid cursor position for id ${id}: ${from}`); // Debug statement } } this.decorations = Decoration.set( presenceDecorations, // Without this argument, we get the following error: // Uncaught Error: Ranges must be added sorted by `from` position and `startSide` true, ); // Somehow this triggers re-rendering of the Decorations. // Not sure if this is the correct usage of the API. // Inspired by https://github.com/yjs/y-codemirror.next/blob/main/src/y-remote-selections.js // Set timeout so that the current CodeMirror update finishes // before the next ones that render presence begin. setTimeout(() => { view.dispatch({ annotations: [presenceAnnotation.of(true)], }); }, 0); // Auto-follow all users when their presence is broadcast // by scrolling them into view. if (enableAutoFollowRef.current) { this.scrollToCursor(view); } }, ); } // Method to scroll the view to keep the cursor in view scrollToCursor(view) { for (const id in this.cursorPosition) { //getting the cursor position of the other cursor const cursorPos = this.cursorPosition[id]; view.dispatch({ //if the other person's cursor has jumped off screen, we will follow it by scrolling there directly. effects: EditorView.scrollIntoView(cursorPos), }); } } }, { decorations: (v) => v.decorations, }, ), presenceTheme, ]; const presenceAnnotation = Annotation.define(); // Checks that the path of this file // matches the path of the presence. // * If true is returned, the presence is in this file. // * If false is returned, the presence is in another file. // Assumption: start and end path are the same except the cursor position. const pathMatches = (path, presence) => { for (let i = 0; i < path.length; i++) { if (path[i] !== presence.start[i]) { return false; } } return true; }; // Displays a single remote presence cursor. class PresenceWidget extends WidgetType { id: string; color: string; username: Username; timeout: number; constructor( id: string, color: string, username: Username, ) { super(); this.id = id; this.color = color; this.username = username; } eq(other: PresenceWidget) { return other.id === this.id; // return false; } toDOM() { // console.log('inside toDOM'); const span = document.createElement('span'); span.setAttribute('aria-hidden', 'true'); span.className = 'cm-json1-presence'; // This child is what actually displays the presence. // Nested so that the layout is not impacted. // // The initial attempt using the top level span to render // the cursor caused a wonky layout with adjacent characters shifting // left and right by 1 pixel or so. const div = document.createElement('div'); span.appendChild(div); div.style.borderLeft = `1px solid rgba(${this.color})`; // background color behind username const userDiv = document.createElement('div'); userDiv.className = 'remote-cursor-username'; userDiv.style.top = `-20px`; userDiv.style.height = `20px`; userDiv.style.width = `${this.username.length * 12}px`; userDiv.style.backgroundColor = `rgba(${this.color})`; userDiv.style.color = `black`; // userDiv.style.textAlign = `center`; userDiv.appendChild( document.createTextNode(this.username), ); span.appendChild(userDiv); // after 2 seconds of inactivity, username is made less visible this.timeout = window.setTimeout(() => { // userDiv.style.backgroundColor = `rgba(${this.color}, 0.2)`; // userDiv.style.color = 'rgba(0,0,0,0.2)'; userDiv.style.opacity = '0.3'; }, 2000); return span; } // TODO try to use this instead of toDOM // updateDOM(dom: HTMLElement, view: EditorView) { // console.log('inside updateDOM'); // dom.style.opacity = '1'; // window.clearTimeout(this.timeout); // // after 2 seconds of inactivity, username is made less visible // this.timeout = window.setTimeout(() => { // // userDiv.style.backgroundColor = `rgba(${this.color}, 0.2)`; // // userDiv.style.color = 'rgba(0,0,0,0.2)'; // dom.style.opacity = '0.3'; // }, 2000); // return false; // } ignoreEvent() { return false; } } const presenceTheme = EditorView.baseTheme({ '.cm-json1-presence': { position: 'relative', }, '.cm-json1-presence > div': { position: 'absolute', top: '0', bottom: '0', left: '0', right: '0', }, });