vzcode
Version:
Multiplayer code editor system
320 lines (292 loc) • 10.8 kB
text/typescript
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',
},
});