diff --git a/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts b/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts new file mode 100644 index 00000000..e046294f --- /dev/null +++ b/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts @@ -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; + } +} + diff --git a/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts b/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts new file mode 100644 index 00000000..a25e24ce --- /dev/null +++ b/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts @@ -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) +}); diff --git a/src/tee/codemirrorPlugins/remoteCursors/index.ts b/src/tee/codemirrorPlugins/remoteCursors/index.ts new file mode 100644 index 00000000..e661bfad --- /dev/null +++ b/src/tee/codemirrorPlugins/remoteCursors/index.ts @@ -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(); + +// State field to track remote selections and cursors +const remoteStateField = StateField.define({ + 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 +] \ No newline at end of file diff --git a/src/tee/components/MarkdownEditor.tsx b/src/tee/components/MarkdownEditor.tsx index f0dd72de..2be5c39a 100644 --- a/src/tee/components/MarkdownEditor.tsx +++ b/src/tee/components/MarkdownEditor.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { EditorView, @@ -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; @@ -69,6 +72,31 @@ export function MarkdownEditor({ const handleReady = handle.isReady(); + const account = useCurrentAccount(); + + // 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({ @@ -76,6 +104,35 @@ export function MarkdownEditor({ }); }, [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; @@ -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: [ @@ -117,6 +175,7 @@ export function MarkdownEditor({ // Now our custom stuff: Automerge collab, comment threads, etc. automergePlugin, + cursorPlugin, frontmatterPlugin, threadsField, threadDecorations, @@ -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)) { @@ -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. diff --git a/src/tee/index.css b/src/tee/index.css index 701b060f..5438e7db 100644 --- a/src/tee/index.css +++ b/src/tee/index.css @@ -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 -------------------------------- */