From bdc82f9b8351a8f8408d83bb5e11d0b8c634ecb1 Mon Sep 17 00:00:00 2001 From: Peter van Hardenberg Date: Fri, 2 Feb 2024 01:00:10 -0800 Subject: [PATCH 1/8] work in progress on remote-cursors in cm --- .../remoteCursors/CursorWidget.ts | 81 +++++++++++++++++++ .../remoteCursors/RemoteCursorsState.ts | 40 +++++++++ .../remoteCursors/ViewPlugin.ts | 29 +++++++ .../codemirrorPlugins/remoteCursors/index.ts | 19 +++++ src/tee/components/MarkdownEditor.tsx | 30 +++++++ 5 files changed, 199 insertions(+) create mode 100644 src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts create mode 100644 src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts create mode 100644 src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts create mode 100644 src/tee/codemirrorPlugins/remoteCursors/index.ts diff --git a/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts b/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts new file mode 100644 index 00000000..1aa9dd89 --- /dev/null +++ b/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts @@ -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); \ No newline at end of file diff --git a/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts b/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts new file mode 100644 index 00000000..9973f3c5 --- /dev/null +++ b/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts @@ -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(); + +// State field to track remote selections and cursors +export const remoteStateField = StateField.define({ + 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) +}); diff --git a/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts b/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts new file mode 100644 index 00000000..c3b6cf37 --- /dev/null +++ b/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts @@ -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) +}); + diff --git a/src/tee/codemirrorPlugins/remoteCursors/index.ts b/src/tee/codemirrorPlugins/remoteCursors/index.ts new file mode 100644 index 00000000..222bb9f6 --- /dev/null +++ b/src/tee/codemirrorPlugins/remoteCursors/index.ts @@ -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"; \ No newline at end of file diff --git a/src/tee/components/MarkdownEditor.tsx b/src/tee/components/MarkdownEditor.tsx index f0dd72de..778bd53b 100644 --- a/src/tee/components/MarkdownEditor.tsx +++ b/src/tee/components/MarkdownEditor.tsx @@ -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; @@ -69,6 +72,18 @@ export function MarkdownEditor({ const handleReady = handle.isReady(); + const account = useCurrentAccount(); + 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({ @@ -76,6 +91,18 @@ export function MarkdownEditor({ }); }, [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; @@ -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: [ @@ -117,6 +145,8 @@ export function MarkdownEditor({ // Now our custom stuff: Automerge collab, comment threads, etc. automergePlugin, + remoteStateField, + cursorPlugin, frontmatterPlugin, threadsField, threadDecorations, From f974606fba7c267e66e734bfa3ccd021b54666f4 Mon Sep 17 00:00:00 2001 From: Peter van Hardenberg Date: Fri, 2 Feb 2024 08:15:15 -0800 Subject: [PATCH 2/8] crude cursor sharing --- .../remoteCursors/CursorWidget.ts | 29 +++++++++---------- .../remoteCursors/RemoteCursorsState.ts | 2 +- .../remoteCursors/ViewPlugin.ts | 4 +-- .../codemirrorPlugins/remoteCursors/index.ts | 1 + src/tee/components/MarkdownEditor.tsx | 25 +++++++++++----- 5 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts b/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts index 1aa9dd89..4f2f25cb 100644 --- a/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts +++ b/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts @@ -11,30 +11,27 @@ export class CursorWidget extends WidgetType { } 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"); - } - + const element = document.createElement("span"); + element.className = "remote-cursor"; + element.style.borderLeft = `2px solid ${this.color}`; + element.setAttribute("data-user", this.user); + // Initially hide the user name + element.setAttribute("data-show-name", "false"); + // Trigger the animation by toggling an attribute - this.showAndHideName(); + this.showAndHideName(element); - return this.element; + return element; } - showAndHideName() { + showAndHideName(element) { // Reset the animation by removing and re-adding the attribute - this.element.setAttribute("data-show-name", "true"); + 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"); + if (element) { // Check if the element still exists + element.setAttribute("data-show-name", "false"); } }, 1500); // Matches the animation duration } diff --git a/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts b/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts index 9973f3c5..aa372d07 100644 --- a/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts +++ b/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts @@ -13,9 +13,9 @@ export const remoteStateField = StateField.define({ return Decoration.none; }, update(decorations, tr) { - decorations = Decoration.none; 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. diff --git a/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts b/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts index c3b6cf37..23bbe83c 100644 --- a/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts +++ b/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts @@ -1,10 +1,8 @@ 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 { +export const collaborativePlugin = (remoteStateField, setLocalSelections: (s: UserSelectionData) => void, peerId: string, user: UserMetadata) => ViewPlugin.fromClass(class { view: EditorView; constructor(view: EditorView) { this.view = view diff --git a/src/tee/codemirrorPlugins/remoteCursors/index.ts b/src/tee/codemirrorPlugins/remoteCursors/index.ts index 222bb9f6..c453d10b 100644 --- a/src/tee/codemirrorPlugins/remoteCursors/index.ts +++ b/src/tee/codemirrorPlugins/remoteCursors/index.ts @@ -15,5 +15,6 @@ export interface UserSelectionData { selection: SelectionData } +export { remoteCursorTheme } from "./CursorWidget"; export { remoteStateField, setPeerSelectionData } from "./RemoteCursorsState"; export { collaborativePlugin } from "./ViewPlugin"; \ No newline at end of file diff --git a/src/tee/components/MarkdownEditor.tsx b/src/tee/components/MarkdownEditor.tsx index 778bd53b..4e5fc44d 100644 --- a/src/tee/components/MarkdownEditor.tsx +++ b/src/tee/components/MarkdownEditor.tsx @@ -39,7 +39,7 @@ import { threadsField, } from "../codemirrorPlugins/commentThreads"; import { lineWrappingPlugin } from "../codemirrorPlugins/lineWrapping"; -import { collaborativePlugin, remoteStateField, setPeerSelectionData } from "../codemirrorPlugins/remoteCursors"; +import { collaborativePlugin, remoteCursorTheme, remoteStateField, setPeerSelectionData } from "../codemirrorPlugins/remoteCursors"; import { useLocalAwareness, useRemoteAwareness } from "@/vendor/vendored-automerge-repo/packages/automerge-repo-react-hooks/dist"; import { useCurrentAccount } from "@/DocExplorer/account"; @@ -82,7 +82,7 @@ export function MarkdownEditor({ const [, setLocalSelections] = useLocalAwareness({handle, userId, initialState: {}}); const [remoteSelections] = useRemoteAwareness({handle, localUserId: userId}); - + const [lastSelections, setLastSelections] = useState(remoteSelections); // Propagate activeThreadId into the codemirror useEffect(() => { @@ -92,6 +92,14 @@ 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, @@ -101,7 +109,7 @@ export function MarkdownEditor({ editorRoot.current?.dispatch({ effects: setPeerSelectionData.of(peerSelections), }); - }, [remoteSelections]); + }, [remoteSelections, lastSelections]); useEffect(() => { if (!handleReady) { @@ -111,7 +119,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 cursorPlugin = collaborativePlugin(remoteStateField, setLocalSelections, userId, userMetadata); const view = new EditorView({ doc: source, extensions: [ @@ -146,6 +154,7 @@ export function MarkdownEditor({ // Now our custom stuff: Automerge collab, comment threads, etc. automergePlugin, remoteStateField, + remoteCursorTheme, cursorPlugin, frontmatterPlugin, threadsField, @@ -159,7 +168,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)) { @@ -172,17 +181,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. From 117e1984cbebcd388fae4dad11c39c9a11a0bf9c Mon Sep 17 00:00:00 2001 From: Peter van Hardenberg Date: Fri, 2 Feb 2024 08:18:59 -0800 Subject: [PATCH 3/8] fragile selection spans now too --- src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts b/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts index aa372d07..e3d1e9c1 100644 --- a/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts +++ b/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts @@ -26,7 +26,7 @@ export const remoteStateField = StateField.define({ // 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) + Decoration.mark({class: "remote-selection", attributes: {style: `background-color: color-mix(in srgb, ${user.color} 20%, transparent)`}}).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.) From 90322198ad5b12ed4c261c145b7bc11253c9529b Mon Sep 17 00:00:00 2001 From: Peter van Hardenberg Date: Fri, 2 Feb 2024 09:10:38 -0800 Subject: [PATCH 4/8] user colors now propagated --- .../remoteCursors/CursorWidget.ts | 13 ++++++-- .../remoteCursors/ViewPlugin.ts | 6 ++-- src/tee/components/MarkdownEditor.tsx | 31 ++++++++++++++----- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts b/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts index 4f2f25cb..fa8333d6 100644 --- a/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts +++ b/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts @@ -10,10 +10,19 @@ export class CursorWidget extends WidgetType { return other.user === this.user && other.color === this.color; } - toDOM() { + toDOM(view) { + //const cursorCoords = view.coordsAtPos(cursorPos); + const element = document.createElement("span"); element.className = "remote-cursor"; - element.style.borderLeft = `2px solid ${this.color}`; + element.style.borderLeft = `1px solid ${this.color}`; + + element.style.borderLeftWidth = '2px'; + element.style.borderLeftStyle = 'solid'; + element.style.marginLeft = element.style.marginRight = '-1px'; + // element.style.height = (cursorCoords.bottom - cursorCoords.top) * 0.9 + 'px'; + element.style.zIndex = "0"; + element.setAttribute("data-user", this.user); // Initially hide the user name element.setAttribute("data-show-name", "false"); diff --git a/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts b/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts index 23bbe83c..4368ca64 100644 --- a/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts +++ b/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts @@ -1,8 +1,8 @@ import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"; -import { UserSelectionData } from "."; +import { SelectionData } from "."; -export const collaborativePlugin = (remoteStateField, setLocalSelections: (s: UserSelectionData) => void, peerId: string, user: UserMetadata) => ViewPlugin.fromClass(class { +export const collaborativePlugin = (remoteStateField, setLocalSelections: (s: SelectionData) => void) => ViewPlugin.fromClass(class { view: EditorView; constructor(view: EditorView) { this.view = view @@ -19,7 +19,7 @@ export const collaborativePlugin = (remoteStateField, setLocalSelections: (s: Us 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}}) + setLocalSelections({selections, cursor}) } }, { decorations: plugin => plugin.view.state.field(remoteStateField) diff --git a/src/tee/components/MarkdownEditor.tsx b/src/tee/components/MarkdownEditor.tsx index 4e5fc44d..c5ea5d18 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, @@ -73,12 +73,20 @@ export function MarkdownEditor({ const handleReady = handle.isReady(); const account = useCurrentAccount(); + + // TODO: "loading" const userId = account?.contactHandle?.url || "loading"; - const userMetadata = { - peerId: userId, - color: "blue", - name: "Anonymous", - } + const userDoc = account?.contactHandle?.docSync(); + + const [userMetadata, setUserMetadata] = useState({name: "Unnamed User", color: "blue", userId}) + useEffect(() => { + if (userDoc) { + if (userDoc.type === "registered") { + const { color, name } = userDoc; + setUserMetadata((userMetadata) => ({ ...userMetadata, color, name, userId })) + } + } + }, [userId, userDoc]); const [, setLocalSelections] = useLocalAwareness({handle, userId, initialState: {}}); const [remoteSelections] = useRemoteAwareness({handle, localUserId: userId}); @@ -111,6 +119,15 @@ export function MarkdownEditor({ }); }, [remoteSelections, lastSelections]); + const setLocalSelectionsWithUserData = useCallback((selection: SelectionData) => { + const localSelections = { + user: userMetadata, + selection, + userId + } + setLocalSelections(localSelections); + }, [setLocalSelections, userMetadata, userId]) + useEffect(() => { if (!handleReady) { return; @@ -119,7 +136,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(remoteStateField, setLocalSelections, userId, userMetadata); + const cursorPlugin = collaborativePlugin(remoteStateField, setLocalSelectionsWithUserData); const view = new EditorView({ doc: source, extensions: [ From b87dad793a2582397329d364f227dc161b0f6b21 Mon Sep 17 00:00:00 2001 From: Peter van Hardenberg Date: Fri, 2 Feb 2024 14:08:58 -0800 Subject: [PATCH 5/8] continuing to futz with CSS, time to fix the duplicate cursors --- .../remoteCursors/CursorWidget.ts | 66 ++++--------------- .../codemirrorPlugins/remoteCursors/index.ts | 1 - src/tee/components/MarkdownEditor.tsx | 3 +- src/tee/index.css | 26 ++++++++ 4 files changed, 40 insertions(+), 56 deletions(-) diff --git a/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts b/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts index fa8333d6..8ecec76f 100644 --- a/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts +++ b/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts @@ -2,6 +2,8 @@ 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(); } @@ -13,36 +15,29 @@ export class CursorWidget extends WidgetType { toDOM(view) { //const cursorCoords = view.coordsAtPos(cursorPos); - const element = document.createElement("span"); - element.className = "remote-cursor"; - element.style.borderLeft = `1px solid ${this.color}`; - - element.style.borderLeftWidth = '2px'; - element.style.borderLeftStyle = 'solid'; - element.style.marginLeft = element.style.marginRight = '-1px'; - // element.style.height = (cursorCoords.bottom - cursorCoords.top) * 0.9 + 'px'; - element.style.zIndex = "0"; + 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); - element.setAttribute("data-user", this.user); - // Initially hide the user name - element.setAttribute("data-show-name", "false"); + // Initially show the user name + this.element.setAttribute("data-show-name", "true"); // Trigger the animation by toggling an attribute - this.showAndHideName(element); + this.showAndHideName(this.element); - return 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 - setTimeout(() => { + 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 + }}, 1500); // Matches the animation duration } ignoreEvent() { @@ -50,38 +45,3 @@ export class CursorWidget extends WidgetType { } } - -// 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); \ No newline at end of file diff --git a/src/tee/codemirrorPlugins/remoteCursors/index.ts b/src/tee/codemirrorPlugins/remoteCursors/index.ts index c453d10b..222bb9f6 100644 --- a/src/tee/codemirrorPlugins/remoteCursors/index.ts +++ b/src/tee/codemirrorPlugins/remoteCursors/index.ts @@ -15,6 +15,5 @@ export interface UserSelectionData { selection: SelectionData } -export { remoteCursorTheme } from "./CursorWidget"; export { remoteStateField, setPeerSelectionData } from "./RemoteCursorsState"; export { collaborativePlugin } from "./ViewPlugin"; \ No newline at end of file diff --git a/src/tee/components/MarkdownEditor.tsx b/src/tee/components/MarkdownEditor.tsx index c5ea5d18..797a592c 100644 --- a/src/tee/components/MarkdownEditor.tsx +++ b/src/tee/components/MarkdownEditor.tsx @@ -39,7 +39,7 @@ import { threadsField, } from "../codemirrorPlugins/commentThreads"; import { lineWrappingPlugin } from "../codemirrorPlugins/lineWrapping"; -import { collaborativePlugin, remoteCursorTheme, remoteStateField, setPeerSelectionData } from "../codemirrorPlugins/remoteCursors"; +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"; @@ -171,7 +171,6 @@ export function MarkdownEditor({ // Now our custom stuff: Automerge collab, comment threads, etc. automergePlugin, remoteStateField, - remoteCursorTheme, cursorPlugin, frontmatterPlugin, threadsField, diff --git a/src/tee/index.css b/src/tee/index.css index 701b060f..a6968c91 100644 --- a/src/tee/index.css +++ b/src/tee/index.css @@ -5,6 +5,32 @@ 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; + + content: var(--user-name); + position: absolute; + right: 0; + top: -1.5em; + background: color-mix(in srgb, currentColor 20%, transparent); + padding: 2px 4px; + border-radius: 4px; + font-size: 0.75em; + opacity: 1; /* Show the name initially */ + height: 1.5em; +} .cm-content { /* Link style -------------------------------- */ From afc777c8ca2a0d2a67eeaf84fd2d1ac627e67d37 Mon Sep 17 00:00:00 2001 From: Peter van Hardenberg Date: Fri, 2 Feb 2024 23:06:27 -0800 Subject: [PATCH 6/8] use a ref. of course. --- .../remoteCursors/CursorWidget.ts | 4 +- .../remoteCursors/RemoteCursorsState.ts | 28 ++++++++- .../codemirrorPlugins/remoteCursors/index.ts | 63 ++++++++++++++++++- src/tee/components/MarkdownEditor.tsx | 33 +++++----- src/tee/index.css | 4 +- 5 files changed, 112 insertions(+), 20 deletions(-) diff --git a/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts b/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts index 8ecec76f..e046294f 100644 --- a/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts +++ b/src/tee/codemirrorPlugins/remoteCursors/CursorWidget.ts @@ -12,8 +12,10 @@ export class CursorWidget extends WidgetType { return other.user === this.user && other.color === this.color; } - toDOM(view) { + toDOM(view: EditorView) { //const cursorCoords = view.coordsAtPos(cursorPos); + console.log(view) + this.element = document.createElement("span"); this.element.className = "remote-cursor"; diff --git a/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts b/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts index e3d1e9c1..562de697 100644 --- a/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts +++ b/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts @@ -1,11 +1,12 @@ -import {EditorView, Decoration, DecorationSet} from "@codemirror/view" +import {EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate} from "@codemirror/view" import {StateField, StateEffect} from "@codemirror/state" // Effects to update remote selections and cursors -import type { UserSelectionData } from "." +import type { UserData, SelectionData, UserSelectionData } from "." import { CursorWidget } from "./CursorWidget"; export const setPeerSelectionData = StateEffect.define(); +export const setUserData = StateEffect.define(); // State field to track remote selections and cursors export const remoteStateField = StateField.define({ @@ -38,3 +39,26 @@ export const remoteStateField = StateField.define({ }, provide: f => EditorView.decorations.from(f) }); + +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 index 222bb9f6..44b6ce4f 100644 --- a/src/tee/codemirrorPlugins/remoteCursors/index.ts +++ b/src/tee/codemirrorPlugins/remoteCursors/index.ts @@ -1,3 +1,5 @@ +import {EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate} from "@codemirror/view" +import {StateField, StateEffect} from "@codemirror/state" export interface UserData { name: string, @@ -15,5 +17,62 @@ export interface UserSelectionData { selection: SelectionData } -export { remoteStateField, setPeerSelectionData } from "./RemoteCursorsState"; -export { collaborativePlugin } from "./ViewPlugin"; \ No newline at end of file +// Effects to update remote selections and cursors +import { CursorWidget } from "./CursorWidget"; + +export const setPeerSelectionData = StateEffect.define(); + +// State field to track remote selections and cursors +export 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) + ); // 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) +}); + +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/components/MarkdownEditor.tsx b/src/tee/components/MarkdownEditor.tsx index 797a592c..696a830d 100644 --- a/src/tee/components/MarkdownEditor.tsx +++ b/src/tee/components/MarkdownEditor.tsx @@ -39,7 +39,7 @@ import { threadsField, } from "../codemirrorPlugins/commentThreads"; import { lineWrappingPlugin } from "../codemirrorPlugins/lineWrapping"; -import { collaborativePlugin, remoteStateField, setPeerSelectionData } from "../codemirrorPlugins/remoteCursors"; +import { collaborativePlugin, remoteStateField, SelectionData, setPeerSelectionData } from "../codemirrorPlugins/remoteCursors"; import { useLocalAwareness, useRemoteAwareness } from "@/vendor/vendored-automerge-repo/packages/automerge-repo-react-hooks/dist"; import { useCurrentAccount } from "@/DocExplorer/account"; @@ -78,15 +78,20 @@ export function MarkdownEditor({ const userId = account?.contactHandle?.url || "loading"; const userDoc = account?.contactHandle?.docSync(); - const [userMetadata, setUserMetadata] = useState({name: "Unnamed User", color: "blue", userId}) - useEffect(() => { - if (userDoc) { - if (userDoc.type === "registered") { - const { color, name } = userDoc; - setUserMetadata((userMetadata) => ({ ...userMetadata, color, name, userId })) - } - } - }, [userId, userDoc]); + // 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}); @@ -121,12 +126,12 @@ export function MarkdownEditor({ const setLocalSelectionsWithUserData = useCallback((selection: SelectionData) => { const localSelections = { - user: userMetadata, + user: userMetadataRef.current, // Access the current value of the ref selection, - userId - } + userId: userMetadataRef.current.userId // Ensure you're using the ref's current value + }; setLocalSelections(localSelections); - }, [setLocalSelections, userMetadata, userId]) + }, [setLocalSelections, userMetadataRef]) useEffect(() => { if (!handleReady) { diff --git a/src/tee/index.css b/src/tee/index.css index a6968c91..5438e7db 100644 --- a/src/tee/index.css +++ b/src/tee/index.css @@ -19,12 +19,14 @@ 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; - background: color-mix(in srgb, currentColor 20%, transparent); + color: white; + background: color-mix(in srgb, var(--user-color) 50%, white); padding: 2px 4px; border-radius: 4px; font-size: 0.75em; From 7a29cbb5f1efdbc3952ffc29107f8b1b7bbb35f6 Mon Sep 17 00:00:00 2001 From: Peter van Hardenberg Date: Mon, 12 Feb 2024 10:37:32 -0800 Subject: [PATCH 7/8] remote cursors work --- src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts | 2 -- src/tee/codemirrorPlugins/remoteCursors/index.ts | 10 +++++++--- src/tee/components/MarkdownEditor.tsx | 7 +++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts b/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts index 4368ca64..a25e24ce 100644 --- a/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts +++ b/src/tee/codemirrorPlugins/remoteCursors/ViewPlugin.ts @@ -1,5 +1,4 @@ import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"; - import { SelectionData } from "."; export const collaborativePlugin = (remoteStateField, setLocalSelections: (s: SelectionData) => void) => ViewPlugin.fromClass(class { @@ -24,4 +23,3 @@ export const collaborativePlugin = (remoteStateField, setLocalSelections: (s: Se }, { decorations: plugin => plugin.view.state.field(remoteStateField) }); - diff --git a/src/tee/codemirrorPlugins/remoteCursors/index.ts b/src/tee/codemirrorPlugins/remoteCursors/index.ts index 44b6ce4f..e661bfad 100644 --- a/src/tee/codemirrorPlugins/remoteCursors/index.ts +++ b/src/tee/codemirrorPlugins/remoteCursors/index.ts @@ -23,7 +23,7 @@ import { CursorWidget } from "./CursorWidget"; export const setPeerSelectionData = StateEffect.define(); // State field to track remote selections and cursors -export const remoteStateField = StateField.define({ +const remoteStateField = StateField.define({ create() { return Decoration.none; }, @@ -42,7 +42,7 @@ export const remoteStateField = StateField.define({ // 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) - ); // 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}); @@ -54,7 +54,7 @@ export const remoteStateField = StateField.define({ provide: f => EditorView.decorations.from(f) }); -export const collaborativePlugin = (remoteStateField, setLocalSelections: (s: SelectionData) => void) => ViewPlugin.fromClass(class { +const emitterPlugin = (setLocalSelections: (s: SelectionData) => void) => ViewPlugin.fromClass(class { view: EditorView; constructor(view: EditorView) { this.view = view @@ -76,3 +76,7 @@ export const collaborativePlugin = (remoteStateField, setLocalSelections: (s: Se }, { 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 696a830d..2be5c39a 100644 --- a/src/tee/components/MarkdownEditor.tsx +++ b/src/tee/components/MarkdownEditor.tsx @@ -39,7 +39,7 @@ import { threadsField, } from "../codemirrorPlugins/commentThreads"; import { lineWrappingPlugin } from "../codemirrorPlugins/lineWrapping"; -import { collaborativePlugin, remoteStateField, SelectionData, setPeerSelectionData } from "../codemirrorPlugins/remoteCursors"; +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"; @@ -75,7 +75,7 @@ export function MarkdownEditor({ const account = useCurrentAccount(); // TODO: "loading" - const userId = account?.contactHandle?.url || "loading"; + const userId = account?.contactHandle?.url; const userDoc = account?.contactHandle?.docSync(); // Initialize userMetadata as a ref @@ -141,7 +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(remoteStateField, setLocalSelectionsWithUserData); + const cursorPlugin = collaborativePlugin(setLocalSelectionsWithUserData); const view = new EditorView({ doc: source, extensions: [ @@ -175,7 +175,6 @@ export function MarkdownEditor({ // Now our custom stuff: Automerge collab, comment threads, etc. automergePlugin, - remoteStateField, cursorPlugin, frontmatterPlugin, threadsField, From 5c47b9c8d2f78666a42cef41ca54014c23c5587e Mon Sep 17 00:00:00 2001 From: Peter van Hardenberg Date: Tue, 20 Feb 2024 11:23:34 -0800 Subject: [PATCH 8/8] remove unused file --- .../remoteCursors/RemoteCursorsState.ts | 64 ------------------- 1 file changed, 64 deletions(-) delete mode 100644 src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts diff --git a/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts b/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts deleted file mode 100644 index 562de697..00000000 --- a/src/tee/codemirrorPlugins/remoteCursors/RemoteCursorsState.ts +++ /dev/null @@ -1,64 +0,0 @@ -import {EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate} from "@codemirror/view" -import {StateField, StateEffect} from "@codemirror/state" - -// Effects to update remote selections and cursors -import type { UserData, SelectionData, UserSelectionData } from "." -import { CursorWidget } from "./CursorWidget"; - -export const setPeerSelectionData = StateEffect.define(); -export const setUserData = StateEffect.define(); - -// State field to track remote selections and cursors -export 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) - ); // 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) -}); - -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) -});