Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Presence First Pass #53

Merged
merged 13 commits into from Mar 9, 2023
8 changes: 7 additions & 1 deletion cli.js
Expand Up @@ -8,6 +8,7 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { json1Presence } from './src/ot.js';
import { randomId } from './src/randomId.js';

const fullPath = process.cwd();

Expand All @@ -18,9 +19,14 @@ const files = fs
.filter((dirent) => dirent.isFile())
.map((dirent) => dirent.name);

// Initialize the document using our data structure for representing files.
// * Keys are file ids, which are random numbers.
// * Values are objects with properties:
// * text - the text content of the file
// * name - the file name
const initialDocument = {};
files.forEach((file) => {
const id = Math.floor(Math.random() * 10000000000);
const id = randomId();
initialDocument[id] = {
text: fs.readFileSync(file, 'utf-8'),
name: file,
Expand Down
65 changes: 59 additions & 6 deletions src/App.jsx
@@ -1,32 +1,61 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import ShareDBClient from 'sharedb-client-browser/dist/sharedb-client-umd.cjs';
import { json1Presence } from './ot';

ShareDBClient.types.register(json1Presence.type);

import { CodeEditor } from './CodeEditor';
import { diff } from './diff';
import { randomId } from './randomId';
import './style.css';

// Register our custom JSON1 OT type that supports presence.
// See https://github.com/vizhub-core/json1-presence
ShareDBClient.types.register(json1Presence.type);

// Establish the singleton ShareDB connection over WebSockets.
// TODO consider using reconnecting WebSocket
const { Connection } = ShareDBClient;
const socket = new WebSocket('ws://' + window.location.host + '/ws');
const connection = new Connection(socket);

function App() {
const [data, setData] = useState(null);

// The ShareDB document.
const [shareDBDoc, setShareDBDoc] = useState(null);

// Local ShareDB presence, for broadcasting our cursor position
// so other clients can see it.
// See https://share.github.io/sharedb/api/local-presence
const [localPresence, setLocalPresence] = useState(null);

// The document-level presence object, which emits
// changes in remote presence.
const [docPresence, setDocPresence] = useState(null);

// The `doc.data` part of the ShareDB document,
// updated on each change to decouple rendering from ShareDB.
const [data, setData] = useState(null);

// True if the file menu is open.
const [isFileMenuOpen, setIsFileMenuOpen] = useState(false);

// The id of the currently open file tab.
const [activeFileId, setActiveFileId] = useState(null);

// The ordered list of tabs.
const [tabList, setTabList] = useState([]);

// Set up the connection to ShareDB.
useEffect(() => {
const shareDBDoc = connection.get('documents', '1');
// Since there is only ever a single document,
// these things are pretty arbitrary.
// * `collection` - the ShareDB collection to use
// * `id` - the id of the ShareDB document to use
const collection = 'documents';
const id = '1';

// Initialize the ShareDB document.
const shareDBDoc = connection.get(collection, id);

// Subscribe to the document to get updates.
// This callback gets called once only.
shareDBDoc.subscribe(() => {
// Expose ShareDB doc to downstream logic.
setShareDBDoc(shareDBDoc);
Expand All @@ -36,12 +65,32 @@ function App() {

// Listen for all changes and update `data`.
// This decouples rendering logic from ShareDB.
// This callback gets called on each change.
shareDBDoc.on('op', (op) => {
setData(shareDBDoc.data);
});

// Set up presence.
// See https://github.com/share/sharedb/blob/master/examples/rich-text-presence/client.js#L53
const docPresence = shareDBDoc.connection.getDocPresence(collection, id);

// Subscribe to receive remote presence updates.
docPresence.subscribe(function (error) {
if (error) throw error;
});

// Set up our local presence for broadcasting this client's presence.
setLocalPresence(docPresence.create(randomId()));

// Store docPresence so child components can listen for changes.
setDocPresence(docPresence);
});

// TODO unsubscribe from presence
// TODO unsubscribe from doc
}, []);

// Called when a tab is closed.
const close = (fileIdToRemove) => (event) => {
// Stop propagation so that the outer listener doesn't fire.
event.stopPropagation();
Expand All @@ -51,6 +100,7 @@ function App() {
setTabList(newTabList);
};

// Called when a file in the sidebar is double-clicked.
const renameFile = useCallback(
(key) => {
const newName = prompt('Enter new name');
Expand All @@ -69,6 +119,7 @@ function App() {
[shareDBDoc]
);

// True if we are ready to actually render the active tab.
const tabValid = data && activeFileId;

return (
Expand Down Expand Up @@ -160,6 +211,8 @@ function App() {
<CodeEditor
className="editor"
shareDBDoc={shareDBDoc}
localPresence={localPresence}
docPresence={docPresence}
activeFileId={activeFileId}
/>
) : null}
Expand Down
14 changes: 12 additions & 2 deletions src/CodeEditor.jsx
@@ -1,13 +1,23 @@
import { useRef, useLayoutEffect } from 'react';
import { getOrCreateEditor } from './getOrCreateEditor';

export const CodeEditor = ({ activeFileId, shareDBDoc }) => {
export const CodeEditor = ({
activeFileId,
shareDBDoc,
localPresence,
docPresence,
}) => {
const ref = useRef();

// useEffect was buggy in that sometimes ref.current was undefined.
// useLayoutEffect seems to solve that issue.
useLayoutEffect(() => {
const editor = getOrCreateEditor(activeFileId, shareDBDoc);
const editor = getOrCreateEditor({
fileId: activeFileId,
shareDBDoc,
localPresence,
docPresence,
});
ref.current.appendChild(editor.dom);

return () => {
Expand Down
28 changes: 22 additions & 6 deletions src/getOrCreateEditor.js
Expand Up @@ -7,6 +7,8 @@ import { css } from '@codemirror/lang-css';
import { oneDark } from '@codemirror/theme-one-dark';
import { json1Sync } from 'codemirror-ot';
import { json1Presence, textUnicode } from './ot';
import { json1PresenceBroadcast } from './json1PresenceBroadcast';
import { json1PresenceDisplay } from './json1PresenceDisplay';

// Singleton cache of CodeMirror instances
// These are created, but never destroyed.
Expand All @@ -16,22 +18,36 @@ import { json1Presence, textUnicode } from './ot';
const editorCache = new Map();

// Gets or creates a CodeMirror editor for the given file id.
export const getOrCreateEditor = (fileId, shareDBDoc) => {
export const getOrCreateEditor = ({
fileId,
shareDBDoc,
localPresence,
docPresence,
}) => {
const data = shareDBDoc.data;

const fileExtension = data[fileId].name.split('.').pop();

// The path for this file in the ShareDB document.
const path = [fileId, 'text'];

const extensions = [
// This plugin implements multiplayer editing,
// real-time synchronozation of changes across clients.
// Does not deal with showing others' cursors.
json1Sync({
shareDBDoc,
path: [fileId, 'text'],
path,
json1: json1Presence,
textUnicode,
}),
// TODO develop another plugin that deals with presence
// See
// * https://github.com/share/sharedb/blob/master/examples/rich-text-presence/server.js#L9
// * https://github.com/yjs/y-codemirror.next/blob/main/src/y-remote-selections.js

// Deals with broadcasting changes in cursor location and selection.
json1PresenceBroadcast({ path, localPresence }),

// Deals with receiving the broadcas from other clients and displaying them.
json1PresenceDisplay({ path, docPresence }),

basicSetup,
oneDark,
];
Expand Down
26 changes: 26 additions & 0 deletions src/json1PresenceBroadcast.js
@@ -0,0 +1,26 @@
import { EditorView } from 'codemirror';

// Deals with broadcasting changes in cursor location and selection.
export const json1PresenceBroadcast = ({ path, localPresence }) =>
// See https://discuss.codemirror.net/t/codemirror-6-proper-way-to-listen-for-changes/2395/10
EditorView.updateListener.of((viewUpdate) => {
// If this update modified the cursor / selection,
// we broadcast the selection update via ShareDB presence.
if (viewUpdate.selectionSet) {
// Isolate the single selection to use for presence.
// Unfortunately JSON1 with presence does not yet
// support multiple selections.
// See https://github.com/ottypes/json1/pull/25#issuecomment-1459616521
const selection = viewUpdate.state.selection.ranges[0];
const { from, to } = selection;

// Translate this into the form expected by json1Presence.
const presence = { start: [...path, from], end: [...path, to] };

// Broadcast presence to remote clients!
// See https://github.com/share/sharedb/blob/master/examples/rich-text-presence/client.js#L71
localPresence.submit(presence, (error) => {
if (error) throw error;
});
}
});
143 changes: 143 additions & 0 deletions src/json1PresenceDisplay.js
@@ -0,0 +1,143 @@
import {
ViewPlugin,
EditorView,
WidgetType,
Decoration,
} from '@codemirror/view';
import { Annotation, RangeSet } from '@codemirror/state';
import { randomId } from './randomId';

// 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 }) => [
ViewPlugin.fromClass(
class {
constructor(view) {
// 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 = {};

// Receive remote presence changes.
docPresence.on('receive', (id, presence) => {
// If presence === null, the user has disconnected / exited
// We also check if the presence is for the current file or not.
if (presence && pathMatches(path, presence)) {
presenceState[id] = presence;
} else {
delete presenceState[id];
}

// Update decorations to reflect new presence state.
// TODO consider mutating this rather than recomputing it on each change.
this.decorations = Decoration.set(
Object.keys(presenceState).map((id) => {
const presence = presenceState[id];
const { start, end } = presence;
const from = start[start.length - 1];
// TODO support selection ranges (first attempt introduced layout errors)
//const to = end[end.length - 1];
return {
from,
//to,
to: from, // Temporary meaure
value: Decoration.widget({
side: -1,
block: false,
widget: new PresenceWidget(id),
}),
};
}),
// 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);
});
}
},
{
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 {
constructor(id) {
super();
this.id = id;
}

eq(other) {
return other.id === this.id;
}

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.
span.appendChild(document.createElement('div'));
return span;
}

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',
borderLeft: '1px solid yellow',
//borderRight: '1px solid black',
},
});