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

Auto-follow: open file feature #781

Merged
merged 5 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/client/CodeEditor/getOrCreateEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { keymap } from '@codemirror/view';
import { basicSetup } from './basicSetup';
import { InteractRule } from '@replit/codemirror-interact';
import rainbowBrackets from '../CodeEditor/rainbowBrackets';
import { TabState } from '../vzReducer';
import { cssLanguage } from '@codemirror/lang-css';
import { javascriptLanguage } from '@codemirror/lang-javascript';

Expand Down Expand Up @@ -115,6 +116,7 @@ export const getOrCreateEditor = ({
customInteractRules,
allowGlobals,
enableAutoFollowRef,
openTab,
}: {
fileId: FileId;

Expand Down Expand Up @@ -148,6 +150,7 @@ export const getOrCreateEditor = ({
// Ref to a boolean that determines whether to
// enable auto-following the cursors of remote users.
enableAutoFollowRef: React.MutableRefObject<boolean>;
openTab: (tabState: TabState) => void;
}): EditorCacheValue => {
// Cache hit
if (editorCache.has(fileId)) {
Expand Down Expand Up @@ -202,6 +205,7 @@ export const getOrCreateEditor = ({
path: textPath,
docPresence,
enableAutoFollowRef,
openTab,
}),
);
}
Expand Down
2 changes: 2 additions & 0 deletions src/client/CodeEditor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const CodeEditor = ({
theme,
codeEditorRef,
enableAutoFollow,
openTab,
} = useContext(VZCodeContext);

// Set `doc.data.isInteracting` to `true` when the user is interacting
Expand Down Expand Up @@ -97,6 +98,7 @@ export const CodeEditor = ({
customInteractRules,
allowGlobals,
enableAutoFollowRef,
openTab,
}),
[
activeFileId,
Expand Down
218 changes: 128 additions & 90 deletions src/client/CodeEditor/json1PresenceDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import {
} from '@codemirror/view';
import { Annotation, RangeSet } from '@codemirror/state';
import ColorHash from 'color-hash';
import { Username } from '../../types';
import {
FileId,
Presence,
PresenceId,
Username,
} from '../../types';
import { TabState } from '../vzReducer';

const debug = false;

Expand All @@ -26,6 +32,12 @@ export const json1PresenceDisplay = ({
path,
docPresence,
enableAutoFollowRef,
openTab,
}: {
path: Array<string>;
docPresence: any;
enableAutoFollowRef: React.MutableRefObject<boolean>;
openTab: (tabState: TabState) => void;
}) => [
ViewPlugin.fromClass(
class {
Expand All @@ -44,113 +56,139 @@ export const json1PresenceDisplay = ({
// 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 = {};
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);
});
// view.dom.addEventListener('scroll', () => {
// this.scrollToCursor(view);
// });
// Receive remote presence changes.
docPresence.on('receive', (id, presence) => {
if (debug) {
console.log(
`Received presence for id ${id}`,
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,
); // Debug statement
}
// 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.
);

// 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];

const presenceDecorations = [];
// 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 FileId,
isTransient: true,
});
}
}
// Update decorations to reflect new presence state.
// TODO consider mutating this rather than recomputing it on each change.

// Object.keys(presenceState).map((id) => {
for (const id of Object.keys(presenceState)) {
const presence = presenceState[id];
const { start, end } = presence;
const from = start[start.length - 1];
const to = end[end.length - 1];
const userColor = new ColorHash({
lightness: 0.75,
}).rgb(id);
const { username } = presence;
const presenceDecorations = [];

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,
),
}),
});
// 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 = new ColorHash({
lightness: 0.75,
}).rgb(id);
const { username } = presence;

// 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: `
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
}
}
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,
);
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);
// 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);
}
});
// 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) {
Expand Down
10 changes: 3 additions & 7 deletions src/client/VZCodeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ export type VZCodeContextValue = {
submitOperation: (
next: (content: VZCodeContent) => VZCodeContent,
) => void;
// TODO pull in this type from ShareDB if possible
localPresence: any;
// TODO pull in this type from ShareDB if possible
docPresence: any;

files: Files | null;
Expand All @@ -76,13 +78,7 @@ export type VZCodeContextValue = {
setActiveFileRight: () => void;

tabList: Array<TabState>;
openTab: ({
fileId,
isTransient,
}: {
fileId: string;
isTransient?: boolean;
}) => void;
openTab: (tabState: TabState) => void;
closeTabs: (fileIds: string[]) => void;

isSettingsOpen: boolean;
Expand Down
Loading