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 1 commit
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
81 changes: 81 additions & 0 deletions src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {EditorView, WidgetType} from "@codemirror/view";

export class CursorWidget extends WidgetType {
element: HTMLElement | null;
constructor(private user: string, private color: string) {
super();
}

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

toDOM() {
// Only create a new element if it doesn't exist yet
if (!this.element) {
this.element = document.createElement("span");
this.element.className = "remote-cursor";
this.element.style.borderLeft = `2px solid ${this.color}`;
this.element.setAttribute("data-user", this.user);
// Initially hide the user name
this.element.setAttribute("data-show-name", "false");
}

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

return this.element;
}

showAndHideName() {
// Reset the animation by removing and re-adding the attribute
this.element.setAttribute("data-show-name", "true");

// Use a timeout to hide the name after a brief period
setTimeout(() => {
if (this.element) { // Check if the element still exists
this.element.setAttribute("data-show-name", "false");
}
}, 1500); // Matches the animation duration
}

ignoreEvent() {
return false;
}
}


// Define your custom theme extension
export const remoteCursorTheme = EditorView.theme({
".cm-editor .remote-cursor[data-show-name='true']::after": {
content: "attr(data-user)",
position: "absolute",
left: "0",
top: "-1.5em",
backgroundColor: "#fff",
padding: "2px 4px",
borderRadius: "4px",
fontSize: "0.75em",
opacity: "1", // Show the name initially
animation: "cm6CursorFadeOut 1.5s ease-out forwards"
},
}, {dark: false /* or true if it's a dark theme */});

// Define the fadeOut animation globally, as it can't be included directly in the theme
const globalStyles = `
@keyframes cm6CursorFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
visibility: hidden;
}
}
`;

// Inject the global styles into the document head
const styleSheet = document.createElement("style");
styleSheet.type = "text/css";
styleSheet.innerText = globalStyles;
document.head.appendChild(styleSheet);
40 changes: 40 additions & 0 deletions src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {EditorView, Decoration, DecorationSet} from "@codemirror/view"
import {StateField, StateEffect} from "@codemirror/state"

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

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

// State field to track remote selections and cursors
export const remoteStateField = StateField.define<DecorationSet>({
create() {
return Decoration.none;
},
update(decorations, tr) {
decorations = Decoration.none;
for (const effect of tr.effects) {
if (effect.is(setPeerSelectionData)) {
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: ${user.color}40;`}}).range(from, to)
); // the 40 is for 25% opacity

// 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)
});
29 changes: 29 additions & 0 deletions src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";

import { remoteStateField } from "./RemoteCursorsState";

import { UserSelectionData } from ".";

export const collaborativePlugin = (setLocalSelections: (s: UserSelectionData) => void, peerId: string, user: UserMetadata) => 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({peerId, user, selection: {selections, cursor}})
}
}, {
decorations: plugin => plugin.view.state.field(remoteStateField)
});

19 changes: 19 additions & 0 deletions src/tee/codemirrorPlugins/remoteCursors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@

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
}

export { remoteStateField, setPeerSelectionData } from "./RemoteCursorsState";
export { collaborativePlugin } from "./ViewPlugin";
30 changes: 30 additions & 0 deletions src/tee/components/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ import {
threadsField,
} from "../codemirrorPlugins/commentThreads";
import { lineWrappingPlugin } from "../codemirrorPlugins/lineWrapping";
import { collaborativePlugin, remoteStateField, 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,37 @@ 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.

const userId = account?.contactHandle?.url || "loading";
const userMetadata = {
peerId: userId,
color: "blue",
name: "Anonymous",
}

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


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

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

useEffect(() => {
if (!handleReady) {
return;
Expand All @@ -84,6 +111,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(setLocalSelections, userId, userMetadata);
const view = new EditorView({
doc: source,
extensions: [
Expand Down Expand Up @@ -117,6 +145,8 @@ export function MarkdownEditor({

// Now our custom stuff: Automerge collab, comment threads, etc.
automergePlugin,
remoteStateField,
cursorPlugin,
frontmatterPlugin,
threadsField,
threadDecorations,
Expand Down