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

Remote cursors pvh #25

Open
wants to merge 8 commits into
base: presence-color
Choose a base branch
from
Open
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
49 changes: 49 additions & 0 deletions src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {EditorView, WidgetType} from "@codemirror/view";

export class CursorWidget extends WidgetType {
element: HTMLElement | null;
timer: NodeJS.Timeout;

constructor(private user: string, private color: string) {
super();
}

eq(other) {
return other.user === this.user && other.color === this.color;
}

toDOM(view: EditorView) {
//const cursorCoords = view.coordsAtPos(cursorPos);
console.log(view)


this.element = document.createElement("span");
this.element.className = "remote-cursor";
this.element.style.setProperty("--user-name", `"${this.user}"`);
this.element.style.setProperty("--user-color", this.color);

// Initially show the user name
this.element.setAttribute("data-show-name", "true");

// Trigger the animation by toggling an attribute
this.showAndHideName(this.element);

return this.element;
}

showAndHideName(element) {
// Reset the animation by removing and re-adding the attribute
element.setAttribute("data-show-name", "true");
// Use a timeout to hide the name after a brief period
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
if (element) { // Check if the element still exists
element.setAttribute("data-show-name", "false");
}}, 1500); // Matches the animation duration
}

ignoreEvent() {
return false;
}
}

25 changes: 25 additions & 0 deletions src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { SelectionData } from ".";

export const collaborativePlugin = (remoteStateField, setLocalSelections: (s: SelectionData) => void) => ViewPlugin.fromClass(class {
view: EditorView;
constructor(view: EditorView) {
this.view = view
this.emitLocalChanges(view);
}

update(update: ViewUpdate) {
if (update.selectionSet || update.docChanged) {
this.emitLocalChanges(update.view);
}
}

emitLocalChanges(view: EditorView) {
const {state} = view;
const selections = state.selection.ranges.map(r => ({from: r.from, to: r.to}));
const cursor = state.selection.main.head;
setLocalSelections({selections, cursor})
}
}, {
decorations: plugin => plugin.view.state.field(remoteStateField)
});
82 changes: 82 additions & 0 deletions src/tee/codemirrorPlugins/remoteCursors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate} from "@codemirror/view"
import {StateField, StateEffect} from "@codemirror/state"

export interface UserData {
name: string,
color: string
}

export interface SelectionData {
selections: {from: number, to: number}[],
cursor: number
}

export interface UserSelectionData {
peerId: string,
user: UserData,
selection: SelectionData
}

// Effects to update remote selections and cursors
import { CursorWidget } from "./CursorWidget";

export const setPeerSelectionData = StateEffect.define<UserSelectionData[]>();

// State field to track remote selections and cursors
const remoteStateField = StateField.define<DecorationSet>({
create() {
return Decoration.none;
},
update(decorations, tr) {
for (const effect of tr.effects) {
if (effect.is(setPeerSelectionData)) {
decorations = Decoration.none;
effect.value.forEach(({user, selection}) => {
if (!user || !selection) { console.log("missing", user, selection); return }
// Make a widget for the cursor position.
const widget = Decoration.widget({
widget: new CursorWidget(user.name, user.color),
side: 1,
}).range(selection.cursor);

// Now mark for highlight any selected ranges.
const ranges = selection.selections.filter(({from, to}) => (from !== to)).map(({from, to}) =>
Decoration.mark({class: "remote-selection", attributes: {style: `background-color: color-mix(in srgb, ${user.color} 20%, transparent)`}}).range(from, to)
);

// Add all this to the decorations set. (We could optimize this by avoiding recreating unchanged values later.)
decorations = decorations.update({add: [widget, ...ranges], sort: true});
});
}
}
return decorations;
},
provide: f => EditorView.decorations.from(f)
});

const emitterPlugin = (setLocalSelections: (s: SelectionData) => void) => ViewPlugin.fromClass(class {
view: EditorView;
constructor(view: EditorView) {
this.view = view
this.emitLocalChanges(view);
}

update(update: ViewUpdate) {
if (update.selectionSet || update.docChanged) {
this.emitLocalChanges(update.view);
}
}

emitLocalChanges(view: EditorView) {
const {state} = view;
const selections = state.selection.ranges.map(r => ({from: r.from, to: r.to}));
const cursor = state.selection.main.head;
setLocalSelections({selections, cursor})
}
}, {
decorations: plugin => plugin.view.state.field(remoteStateField)
});

export const collaborativePlugin = (setLocalSelections: (s: SelectionData) => void) => [
emitterPlugin(setLocalSelections), remoteStateField
]
69 changes: 64 additions & 5 deletions src/tee/components/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";

import {
EditorView,
Expand Down Expand Up @@ -39,6 +39,9 @@ import {
threadsField,
} from "../codemirrorPlugins/commentThreads";
import { lineWrappingPlugin } from "../codemirrorPlugins/lineWrapping";
import { type SelectionData, collaborativePlugin, setPeerSelectionData } from "../codemirrorPlugins/remoteCursors";
import { useLocalAwareness, useRemoteAwareness } from "@/vendor/vendored-automerge-repo/packages/automerge-repo-react-hooks/dist";
import { useCurrentAccount } from "@/DocExplorer/account";

export type TextSelection = {
from: number;
Expand Down Expand Up @@ -69,13 +72,67 @@ export function MarkdownEditor({

const handleReady = handle.isReady();

const account = useCurrentAccount();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PS: this hook is driving me crazy

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

specifically, it's annoying that i have to check several levels of hierarchy (account, currentaccount, doc) and then i still have the type / anonymous stuff where if the type is anonymous i have to reinvent the name & a color every time...

my suggestion is that we set the name to anonymous (at least in the hook but i think in the doc too) and pick a random presence color and leave it on the contact doc.


// TODO: "loading"
const userId = account?.contactHandle?.url;
const userDoc = account?.contactHandle?.docSync();

// Initialize userMetadata as a ref
const userMetadataRef = useRef({name: "Anonymous", color: "pink", userId});

useEffect(() => {
if (userDoc) {
if (userDoc.type === "registered") {
const { color, name } = userDoc;
// Update the ref directly
userMetadataRef.current = { ...userMetadataRef.current, color, name, userId };
} else {
userMetadataRef.current = { ...userMetadataRef.current, userId };
}
}
}, [userId, userDoc]);

const [, setLocalSelections] = useLocalAwareness({handle, userId, initialState: {}});
const [remoteSelections] = useRemoteAwareness({handle, localUserId: userId});
const [lastSelections, setLastSelections] = useState(remoteSelections);

// Propagate activeThreadId into the codemirror
useEffect(() => {
editorRoot.current?.dispatch({
effects: setThreadsEffect.of(threadsWithPositions),
});
}, [threadsWithPositions]);

useEffect(() => {
// compare the new selections to the last selections
// if they are different, update the codemirror
// we need to do a deep comparison because the object reference will change
if (JSON.stringify(remoteSelections) === JSON.stringify(lastSelections)) {
return // bail out
}
setLastSelections(remoteSelections);

const peerSelections = Object.entries(remoteSelections).map(([userId, selection]) => {
return {
userId,
...selection
}
})
editorRoot.current?.dispatch({
effects: setPeerSelectionData.of(peerSelections),
});
}, [remoteSelections, lastSelections]);

const setLocalSelectionsWithUserData = useCallback((selection: SelectionData) => {
const localSelections = {
user: userMetadataRef.current, // Access the current value of the ref
selection,
userId: userMetadataRef.current.userId // Ensure you're using the ref's current value
};
setLocalSelections(localSelections);
}, [setLocalSelections, userMetadataRef])

useEffect(() => {
if (!handleReady) {
return;
Expand All @@ -84,6 +141,7 @@ export function MarkdownEditor({
const source = doc.content; // this should use path
const automergePlugin = amgPlugin(doc, path);
const semaphore = new PatchSemaphore(automergePlugin);
const cursorPlugin = collaborativePlugin(setLocalSelectionsWithUserData);
const view = new EditorView({
doc: source,
extensions: [
Expand Down Expand Up @@ -117,6 +175,7 @@ export function MarkdownEditor({

// Now our custom stuff: Automerge collab, comment threads, etc.
automergePlugin,
cursorPlugin,
frontmatterPlugin,
threadsField,
threadDecorations,
Expand All @@ -129,7 +188,7 @@ export function MarkdownEditor({
dispatch(transaction, view) {
// TODO: can some of these dispatch handlers be factored out into plugins?
try {
const newSelection = transaction.newSelection.ranges[0];
/*const newSelection = transaction.newSelection.ranges[0];
if (transaction.newSelection !== view.state.selection) {
// set the active thread id if our selection is in a thread
for (const thread of view.state.field(threadsField)) {
Expand All @@ -142,17 +201,17 @@ export function MarkdownEditor({
}
setActiveThreadId(null);
}
}
}*/
view.update([transaction]);
semaphore.reconcile(handle, view);
const selection = view.state.selection.ranges[0];
/*const selection = view.state.selection.ranges[0];
setSelection({
from: selection.from,
to: selection.to,
yCoord:
-1 * view.scrollDOM.getBoundingClientRect().top +
view.coordsAtPos(selection.from).top,
});
});*/
} catch (e) {
// If we hit an error in dispatch, it can lead to bad situations where
// the editor has crashed and isn't saving data but the user keeps typing.
Expand Down
28 changes: 28 additions & 0 deletions src/tee/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@
display: none;
}

.remote-cursor {
display: inline;
position: relative;
color: var(--user-color);
border-left: 2px solid var(--user-color);
margin-left: -1px;
margin-right: -1px;
position: relative;
}

.remote-cursor[data-show-name=true]::after {
word-break: normal;
overflow-wrap: normal;
text-wrap: nowrap;
font-family: "Merriweather Sans", sans-serif;

content: var(--user-name);
position: absolute;
right: 0;
top: -1.5em;
color: white;
background: color-mix(in srgb, var(--user-color) 50%, white);
padding: 2px 4px;
border-radius: 4px;
font-size: 0.75em;
opacity: 1; /* Show the name initially */
height: 1.5em;
}

.cm-content {
/* Link style -------------------------------- */
Expand Down