Add API for propagating cursor locations (#61)

This commit is contained in:
Andras Schmelczer 2025-06-08 20:20:52 +01:00 committed by GitHub
parent f97193e287
commit e8b9bf40c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 1930 additions and 2229 deletions

View file

@ -1,39 +1,39 @@
{
"name": "vault-link-obsidian-plugin",
"version": "0.3.15",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "jest",
"version": "node version-bump.mjs"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.15.27",
"css-loader": "^7.1.2",
"date-fns": "^4.1.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.3.0",
"jest": "^29.7.0",
"mini-css-extract-plugin": "^2.9.2",
"obsidian": "1.8.7",
"resolve-url-loader": "^5.0.0",
"sass": "^1.89.0",
"sass-loader": "^16.0.5",
"sync-client": "file:../sync-client",
"terser-webpack-plugin": "^5.3.14",
"ts-jest": "^29.3.4",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"typescript": "5.8.3",
"url": "^0.11.4",
"virtual-scroller": "^1.13.1",
"webpack": "^5.98.0",
"webpack-cli": "^6.0.1"
}
}
"name": "vault-link-obsidian-plugin",
"version": "0.3.15",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "jest",
"version": "node version-bump.mjs"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.15.30",
"css-loader": "^7.1.2",
"date-fns": "^4.1.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.3.0",
"jest": "^29.7.0",
"mini-css-extract-plugin": "^2.9.2",
"obsidian": "1.8.7",
"resolve-url-loader": "^5.0.0",
"sass": "^1.89.1",
"sass-loader": "^16.0.5",
"sync-client": "file:../sync-client",
"terser-webpack-plugin": "^5.3.14",
"ts-jest": "^29.3.4",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"typescript": "5.8.3",
"url": "^0.11.4",
"virtual-scroller": "^1.13.1",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
}

View file

@ -7,6 +7,7 @@ import type {
} from "sync-client";
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
import { getCursorsFromEditor } from "./views/cursors/get-cursors-from-editor";
export class ObsidianFileSystemOperations implements FileSystemOperations {
public constructor(
@ -78,26 +79,19 @@ export class ObsidianFileSystemOperations implements FileSystemOperations {
if (view?.file?.path === path) {
const text = view.editor.getValue();
const cursors = view.editor
.listSelections()
.flatMap(({ anchor, head }, i) => [
const cursors = getCursorsFromEditor(view.editor).flatMap(
({ id, start: anchor, end: head }) => [
{
id: 2 * i,
characterPosition: lineAndColumnToPosition(
text,
anchor.line,
anchor.ch
)
id: 2 * id,
characterPosition: anchor
},
{
id: 2 * i + 1,
characterPosition: lineAndColumnToPosition(
text,
head.line,
head.ch
)
id: 2 * id + 1,
characterPosition: head
}
]);
]
);
const result = updater({
text,

View file

@ -0,0 +1,9 @@
export function getRandomColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash << 5) - hash + name.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
const normalised = hash / 0x7fffffff;
return `hsl(${Math.abs(normalised * 360)}, 55%, 55%)`; // HSL color
}

View file

@ -1,24 +1,36 @@
import type {
Editor,
EventRef,
MarkdownFileInfo,
MarkdownView,
TAbstractFile,
Workspace,
WorkspaceLeaf
} from "obsidian";
import type { MarkdownView } from "obsidian";
import { Platform, Plugin, TFile } from "obsidian";
import "../manifest.json";
import { HistoryView } from "./views/history/history-view";
import { StatusBar } from "./views/status-bar/status-bar";
import { LogsView } from "./views/logs/logs-view";
import { StatusDescription } from "./views/status-description/status-description";
import type { CursorSpan, RelativePath } from "sync-client";
import { SyncClient, rateLimit, DEFAULT_SETTINGS } from "sync-client";
import { ObsidianFileSystemOperations } from "./obsidian-file-system";
import { SyncSettingsTab } from "./views/settings/settings-tab";
import { registerConsoleForLogging } from "./utils/register-console-for-logging";
import { updateEditorStatusDisplay } from "./views/editor-sync-line/editor-sync-line";
import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme";
import {
remoteCursorsPlugin,
setCursors
} from "./views/cursors/remote-cursors-plugin";
import { getCursorsFromEditor } from "./views/cursors/get-cursors-from-editor";
import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener";
const MIN_WAIT_BETWEEN_UPDATES_IN_MS = 250;
export default class VaultLinkPlugin extends Plugin {
private readonly disposables: (() => void)[] = [];
private readonly disposables: (() => unknown)[] = [];
private settingsTab: SyncSettingsTab | undefined;
private client!: SyncClient;
private readonly rateLimitedUpdatesPerFile = new Map<
@ -61,18 +73,36 @@ export default class VaultLinkPlugin extends Plugin {
this.registerView(
HistoryView.TYPE,
(leaf) => new HistoryView(leaf, this.client)
(leaf) => new HistoryView(this.client, leaf)
);
this.registerView(
LogsView.TYPE,
(leaf) => new LogsView(this.client, leaf)
);
this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]);
this.client.addRemoteCursorsUpdateListener((cursors) => {
setCursors(cursors, this.app);
});
const cursorListener = new LocalCursorUpdateListener(
this.client,
this.app.workspace
);
this.disposables.push(() => {
cursorListener.dispose();
});
this.app.workspace.updateOptions();
this.addRibbonIcon(
HistoryView.ICON,
"Open VaultLink events",
async (_: MouseEvent) => this.activateView(HistoryView.TYPE)
);
this.addRibbonIcon(
LogsView.ICON,
"Open VaultLink logs",
@ -181,7 +211,7 @@ export default class VaultLinkPlugin extends Plugin {
this.client.syncLocallyUpdatedFile({
relativePath: path
}),
250
MIN_WAIT_BETWEEN_UPDATES_IN_MS
)
);
}

View file

@ -0,0 +1,17 @@
import type { Editor } from "obsidian";
import { lineAndColumnToPosition } from "../../utils/line-and-column-to-position";
export interface Cursor {
id: number;
start: number;
end: number;
}
export function getCursorsFromEditor(editor: Editor): Cursor[] {
const text = editor.getValue();
return editor.listSelections().map(({ anchor, head }, i) => ({
id: i,
start: lineAndColumnToPosition(text, anchor.line, anchor.ch),
end: lineAndColumnToPosition(text, head.line, head.ch)
}));
}

View file

@ -0,0 +1,58 @@
import type { Workspace } from "obsidian";
import { EventRef, Editor, MarkdownView, MarkdownFileInfo } from "obsidian";
import type { Logger, SyncClient } from "sync-client";
import type { Cursor } from "./get-cursors-from-editor";
import { getCursorsFromEditor } from "./get-cursors-from-editor";
export class LocalCursorUpdateListener {
private static readonly UPDATE_INTERVAL_MS = 50;
private readonly eventHandle: NodeJS.Timeout;
private lastCursorState: Record<string, Cursor[]> = {};
public constructor(
private readonly client: SyncClient,
private readonly workspace: Workspace
) {
this.eventHandle = setInterval(() => {
this.updateAllCursors();
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
}
public dispose(): void {
clearInterval(this.eventHandle);
}
private updateAllCursors(): void {
const currentCursors = this.getAllCursors();
if (
JSON.stringify(this.lastCursorState) ===
JSON.stringify(currentCursors)
) {
return;
}
this.lastCursorState = currentCursors;
this.client
.updateLocalCursors(currentCursors)
.catch((error: unknown) => {
this.client.logger.error(
`Failed to update local cursors: ${error}`
);
});
}
private getAllCursors(): Record<string, Cursor[]> {
const cursors: Record<string, Cursor[]> = {};
this.workspace
.getLeavesOfType("markdown")
.map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView)
.forEach((view) => {
const { file } = view;
if (!file) {
return;
}
cursors[file.path] = getCursorsFromEditor(view.editor);
});
return cursors;
}
}

View file

@ -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
}
});

View file

@ -0,0 +1,46 @@
import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state";
import {
ViewUpdate,
ViewPlugin,
Decoration,
WidgetType
} from "@codemirror/view";
import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view";
export class RemoteCursorWidget extends WidgetType {
public constructor(
private readonly color: string,
private readonly name: string
) {
super();
}
public toDOM(editor: EditorView): HTMLElement {
return editor.contentDOM.createEl(
"span",
{
cls: "selection-caret",
attr: {
style: `background-color: ${this.color}; border-color: ${this.color}`
}
},
(span) => {
span.createEl("div", {
cls: "stick"
});
span.createEl("div", {
cls: "dot"
});
span.createEl("div", {
cls: "info",
text: this.name
});
}
);
}
public eq(other: RemoteCursorWidget): boolean {
return other.color === this.color && other.name === this.name;
}
}

View file

@ -0,0 +1,134 @@
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<Decoration>[] = [];
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): void {
cursors = clients.flatMap((client) => {
const clientCursors = client.cursors;
return Object.keys(clientCursors).flatMap((path) => {
const spans = clientCursors[path];
return spans
? spans.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
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const editor = view.editor.cm as EditorView;
editor.dispatch({
effects: [forceUpdate.of(null)]
});
});
}

View file

@ -18,8 +18,8 @@ export class HistoryView extends ItemView {
>();
public constructor(
leaf: WorkspaceLeaf,
private readonly client: SyncClient
private readonly client: SyncClient,
leaf: WorkspaceLeaf
) {
super(leaf);
this.icon = HistoryView.ICON;

View file

@ -11,7 +11,7 @@ export class StatusDescription {
private lastRemaining: number | undefined;
private lastConnectionState: NetworkConnectionStatus | undefined;
private statusChangeListeners: (() => void)[] = [];
private statusChangeListeners: (() => unknown)[] = [];
public constructor(private readonly syncClient: SyncClient) {
void this.updateConnectionState();

View file

@ -12,7 +12,16 @@ module.exports = (env, argv) => ({
ignored: "**/node_modules"
},
externals: {
obsidian: "commonjs obsidian"
obsidian: "commonjs obsidian",
electron: "commonjs electron",
"@codemirror/autocomplete": "commonjs @codemirror/autocomplete",
"@codemirror/collab": "commonjs @codemirror/collab",
"@codemirror/commands": "commonjs @codemirror/commands",
"@codemirror/language": "commonjs @codemirror/language",
"@codemirror/lint": "commonjs @codemirror/lint",
"@codemirror/search": "commonjs @codemirror/search",
"@codemirror/state": "commonjs @codemirror/state",
"@codemirror/view": "commonjs @codemirror/view"
},
optimization: {
minimizer: [