From deca4c4dc2cc010ac9710a8ee99c75980191ae6e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 8 Jun 2025 12:08:20 +0100 Subject: [PATCH] Refine cursor look --- .../src/utils/get-random-color.ts | 2 +- .../src/views/cursors/remote-cursor-theme.ts | 63 +++++++++ .../remote-cursor-widget.ts | 17 ++- .../views/cursors/remote-cursors-plugin.ts | 129 ++++++++++++++++++ .../remote-cursors/remote-cursor-theme.ts | 67 --------- .../remote-cursors/remote-cursors-plugin.ts | 110 --------------- 6 files changed, 201 insertions(+), 187 deletions(-) create mode 100644 frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts rename frontend/obsidian-plugin/src/views/{remote-cursors => cursors}/remote-cursor-widget.ts (70%) create mode 100644 frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts delete mode 100644 frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-theme.ts delete mode 100644 frontend/obsidian-plugin/src/views/remote-cursors/remote-cursors-plugin.ts diff --git a/frontend/obsidian-plugin/src/utils/get-random-color.ts b/frontend/obsidian-plugin/src/utils/get-random-color.ts index eadd0927..5b2d33dc 100644 --- a/frontend/obsidian-plugin/src/utils/get-random-color.ts +++ b/frontend/obsidian-plugin/src/utils/get-random-color.ts @@ -5,5 +5,5 @@ export function getRandomColor(name: string): string { hash |= 0; // Convert to 32bit integer } const normalised = hash / 0x7fffffff; - return `hsl(${Math.abs(normalised * 360)}, 70%, 30%)`; // HSL color + return `hsl(${Math.abs(normalised * 360)}, 55%, 55%)`; // HSL color } diff --git a/frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts new file mode 100644 index 00000000..3af2692d --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts @@ -0,0 +1,63 @@ +import { EditorView } from "@codemirror/view"; + +const CARET_WIDTH = 2; +const DOT_RADIUS = 4; + +export const remoteCursorsTheme = EditorView.baseTheme({ + ".selection-caret": { + position: "relative" + }, + + ".selection-caret > *": { + position: "absolute", + backgroundColor: "inherit" + }, + + ".selection-caret > .stick": { + left: 0, + top: 0, + transform: "translateX(-50%)", + width: `${CARET_WIDTH}px`, + height: "100%", + display: "block", + borderRadius: `${CARET_WIDTH / 2}px`, + animation: "blink-stick 1s steps(1) infinite" + }, + + "@keyframes blink-stick": { + "0%, 100%": { opacity: 1 }, + "50%": { opacity: 0 } + }, + + ".selection-caret > .dot": { + borderRadius: "50%", + width: `${DOT_RADIUS * 2}px`, + height: `${DOT_RADIUS * 2}px`, + top: `-${DOT_RADIUS}px`, + left: `-${DOT_RADIUS}px`, + transition: "transform .3s ease-in-out", + transformOrigin: "bottom center", + boxSizing: "border-box" + }, + + ".selection-caret:hover > .dot": { + transform: "scale(0)" + }, + + ".selection-caret > .info": { + top: "-1.3em", + left: `-${CARET_WIDTH / 2}px`, + fontSize: "0.9em", + userSelect: "none", + color: "white", + padding: "0 2px", + transition: "opacity .3s ease-in-out", + opacity: 0, + whiteSpace: "nowrap", + borderRadius: "3px 3px 3px 0" + }, + + ".selection-caret:hover > .info": { + opacity: 1 + } +}); diff --git a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-widget.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-widget.ts similarity index 70% rename from frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-widget.ts rename to frontend/obsidian-plugin/src/views/cursors/remote-cursor-widget.ts index 767311ea..e3273484 100644 --- a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-widget.ts +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-widget.ts @@ -1,13 +1,12 @@ import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state"; import { - EditorView, ViewUpdate, ViewPlugin, Decoration, WidgetType } from "@codemirror/view"; -import type { PluginValue, DecorationSet } from "@codemirror/view"; +import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view"; export class RemoteCursorWidget extends WidgetType { public constructor( @@ -21,27 +20,27 @@ export class RemoteCursorWidget extends WidgetType { return editor.contentDOM.createEl( "span", { - cls: "SelectionCaret", + cls: "selection-caret", attr: { style: `background-color: ${this.color}; border-color: ${this.color}` } }, (span) => { - span.appendText("\u2060"); span.createEl("div", { - cls: "SelectionCaretDot" + cls: "stick" }); - span.appendText("\u2060"); span.createEl("div", { - cls: "SelectionInfo", + cls: "dot" + }); + span.createEl("div", { + cls: "info", text: this.name }); - span.appendText("\u2060"); } ); } - public eq(other: RemoteCursorWidget) { + public eq(other: RemoteCursorWidget): boolean { return other.color === this.color && other.name === this.name; } } diff --git a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts new file mode 100644 index 00000000..2142fdff --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts @@ -0,0 +1,129 @@ +import type { Range } from "@codemirror/state"; +import { RangeSet, Annotation, AnnotationType } from "@codemirror/state"; +import { ViewPlugin, Decoration, WidgetType } from "@codemirror/view"; + +import type { + PluginValue, + DecorationSet, + EditorView, + ViewUpdate +} from "@codemirror/view"; +import { RemoteCursorWidget } from "./remote-cursor-widget"; +import type { ClientCursors, CursorSpan } from "sync-client"; +import type { App } from "obsidian"; +import { MarkdownView } from "obsidian"; + +let cursors: { + name: string; + path: string; + span: CursorSpan; +}[] = []; + +import { StateEffect } from "@codemirror/state"; +import { getRandomColor } from "src/utils/get-random-color"; + +const forceUpdate = StateEffect.define(); + +export class RemoteCursorsPluginValue implements PluginValue { + public decorations: DecorationSet = RangeSet.of([]); + + public update(update: ViewUpdate): void { + const decorations: Range[] = []; + + cursors.forEach(({ name, span: { start, end } }) => { + const color = getRandomColor(name); + const startLine = update.view.state.doc.lineAt(start); + const endLine = update.view.state.doc.lineAt(end); + + const attributes = { + style: `background-color: ${color};` + }; + + if (startLine.number === endLine.number) { + // selected content in a single line. + decorations.push({ + from: start, + to: end, + value: Decoration.mark({ + attributes + }) + }); + } else { + // selected content in multiple lines + // first, render text-selection in the first line + decorations.push({ + from: start, + to: startLine.from + startLine.length, + value: Decoration.mark({ + attributes + }) + }); + + // render text-selection in the lines between the first and last line + for (let i = startLine.number + 1; i < endLine.number; i++) { + const currentLine = update.view.state.doc.line(i); + decorations.push({ + from: currentLine.from, + to: currentLine.to, + value: Decoration.mark({ + attributes + }) + }); + } + + // render text-selection in the last line + decorations.push({ + from: endLine.from, + to: end, + value: Decoration.mark({ + attributes + }) + }); + } + + decorations.push({ + from: end, + to: end, + value: Decoration.widget({ + side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection + block: false, + widget: new RemoteCursorWidget(color, name) + }) + }); + }); + + this.decorations = Decoration.set(decorations, true); + } +} + +export const remoteCursorsPlugin = ViewPlugin.fromClass( + RemoteCursorsPluginValue, + { + decorations: (v) => v.decorations + } +); + +export function setCursors(clients: ClientCursors[], app: App) { + cursors = clients.flatMap((client) => { + return Object.keys(client.cursors).flatMap((path) => + client.cursors[path]!.map((span) => ({ + name: client.userName, + path, + span + })) + ); + }); + + app.workspace + .getLeavesOfType("markdown") + .map((leaf) => leaf.view) + .filter((view) => view instanceof MarkdownView) + .forEach((view) => { + // @ts-expect-error, not typed + const editor = view.editor.cm as EditorView; + + editor.dispatch({ + effects: [forceUpdate.of(null)] + }); + }); +} diff --git a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-theme.ts b/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-theme.ts deleted file mode 100644 index 373ec9f1..00000000 --- a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-theme.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state"; -import { - EditorView, - ViewUpdate, - ViewPlugin, - Decoration, - WidgetType -} from "@codemirror/view"; - -import type { PluginValue, DecorationSet } from "@codemirror/view"; - -export const remoteCursorsTheme = EditorView.baseTheme({ - ".Selection": {}, - ".LineSelection": { - padding: 0, - margin: "0px 2px 0px 4px" - }, - ".SelectionCaret": { - position: "relative", - borderLeft: "1px solid black", - borderRight: "1px solid black", - marginLeft: "-1px", - marginRight: "-1px", - boxSizing: "border-box", - display: "inline" - }, - ".SelectionCaretDot": { - borderRadius: "50%", - position: "absolute", - width: ".4em", - height: ".4em", - top: "-.2em", - left: "-.2em", - backgroundColor: "inherit", - transition: "transform .3s ease-in-out", - boxSizing: "border-box" - }, - ".SelectionCaret:hover > .SelectionCaretDot": { - transformOrigin: "bottom center", - transform: "scale(0)" - }, - ".SelectionInfo": { - position: "absolute", - top: "-1.05em", - left: "-1px", - fontSize: ".75em", - fontFamily: "serif", - fontStyle: "normal", - fontWeight: "normal", - lineHeight: "normal", - userSelect: "none", - color: "white", - paddingLeft: "2px", - paddingRight: "2px", - zIndex: 101, - transition: "opacity .3s ease-in-out", - backgroundColor: "inherit", - // these should be separate - opacity: 0, - transitionDelay: "0s", - whiteSpace: "nowrap" - }, - ".SelectionCaret:hover > .SelectionInfo": { - opacity: 1, - transitionDelay: "0s" - } -}); diff --git a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursors-plugin.ts b/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursors-plugin.ts deleted file mode 100644 index 52e2d5e0..00000000 --- a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursors-plugin.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { RangeSet, Range } from "@codemirror/state"; -import { - EditorView, - ViewUpdate, - ViewPlugin, - Decoration, - WidgetType -} from "@codemirror/view"; - -import type { PluginValue, DecorationSet } from "@codemirror/view"; -import { RemoteCursorWidget } from "./remote-cursor-widget"; - -export class RemoteCursorsPluginValue implements PluginValue { - public decorations: DecorationSet = RangeSet.of([]); - - public constructor(private readonly _editor: EditorView) {} - - public update(update: ViewUpdate) { - const decorations: Array> = []; - - const cursors: { - name: string; - color: string; - anchor: { index: number }; - head: { index: number }; - }[] = [ - { - name: "Alice", - color: "#ff6b6b", - anchor: { index: 10 }, - head: { index: 20 } - } - ]; - - cursors.forEach(({ name, color, anchor, head }) => { - const start = Math.min(anchor.index, head.index); - const end = Math.max(anchor.index, head.index); - const startLine = update.view.state.doc.lineAt(start); - const endLine = update.view.state.doc.lineAt(end); - - if (startLine.number === endLine.number) { - // selected content in a single line. - decorations.push({ - from: start, - to: end, - value: Decoration.mark({ - attributes: { - style: `background-color: ${color}` - }, - class: "Selection" - }) - }); - } else { - // selected content in multiple lines - // first, render text-selection in the first line - decorations.push({ - from: start, - to: startLine.from + startLine.length, - value: Decoration.mark({ - attributes: { - style: `background-color: ${color}` - }, - class: "Selection" - }) - }); - // render text-selection in the last line - decorations.push({ - from: endLine.from, - to: end, - value: Decoration.mark({ - attributes: { - style: `background-color: ${color}` - }, - class: "Selection" - }) - }); - for (let i = startLine.number + 1; i < endLine.number; i++) { - const linePos = update.view.state.doc.line(i).from; - decorations.push({ - from: linePos, - to: linePos, - value: Decoration.line({ - attributes: { - style: `background-color: ${color}`, - class: "LineSelection" - } - }) - }); - } - } - decorations.push({ - from: head.index, - to: head.index, - value: Decoration.widget({ - side: head.index - anchor.index > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection - block: false, - widget: new RemoteCursorWidget(color, name) - }) - }); - }); - this.decorations = Decoration.set(decorations, true); - } -} - -export const remoteCursorsPlugin = ViewPlugin.fromClass( - RemoteCursorsPluginValue, - { - decorations: (v) => v.decorations - } -);