Add API for propagating cursor locations #61

Merged
schmelczer merged 30 commits from asch/show-cursors into main 2025-06-08 20:20:53 +01:00
21 changed files with 137 additions and 67 deletions
Showing only changes of commit 83823b48f2 - Show all commits

Format and lint

Andras Schmelczer 2025-06-08 12:13:35 +01:00
No known key found for this signature in database
GPG key ID: FC8F2C3D3D1A718C

View file

@ -6,7 +6,7 @@ import type {
Workspace, Workspace,
WorkspaceLeaf WorkspaceLeaf
} from "obsidian"; } from "obsidian";
import { MarkdownView } from "obsidian"; import type { MarkdownView } from "obsidian";
import { Platform, Plugin, TFile } from "obsidian"; import { Platform, Plugin, TFile } from "obsidian";
import "../manifest.json"; import "../manifest.json";
import { HistoryView } from "./views/history/history-view"; import { HistoryView } from "./views/history/history-view";
@ -91,7 +91,9 @@ export default class VaultLinkPlugin extends Plugin {
this.client, this.client,
this.app.workspace this.app.workspace
); );
this.disposables.push(() => cursorListener.dispose()); this.disposables.push(() => {
cursorListener.dispose();
});
this.app.workspace.updateOptions(); this.app.workspace.updateOptions();
@ -195,7 +197,9 @@ export default class VaultLinkPlugin extends Plugin {
} }
} }
) )
].forEach((event) => this.registerEvent(event)); ].forEach((event) => {
this.registerEvent(event);
});
} }
private async rateLimitedUpdate(path: string): Promise<void> { private async rateLimitedUpdate(path: string): Promise<void> {

View file

@ -1,12 +1,8 @@
import { import type { Workspace } from "obsidian";
EventRef, import { EventRef, Editor, MarkdownView, MarkdownFileInfo } from "obsidian";
Workspace, import type { Logger, SyncClient } from "sync-client";
Editor, import type { Cursor } from "./get-cursors-from-editor";
MarkdownView, import { getCursorsFromEditor } from "./get-cursors-from-editor";
MarkdownFileInfo
} from "obsidian";
import { SyncClient } from "sync-client";
import { Cursor, getCursorsFromEditor } from "./get-cursors-from-editor";
export class LocalCursorUpdateListener { export class LocalCursorUpdateListener {
private static readonly UPDATE_INTERVAL_MS = 50; private static readonly UPDATE_INTERVAL_MS = 50;
@ -17,10 +13,13 @@ export class LocalCursorUpdateListener {
private readonly client: SyncClient, private readonly client: SyncClient,
private readonly workspace: Workspace private readonly workspace: Workspace
) { ) {
this.eventHandle = setInterval( this.eventHandle = setInterval(() => {
() => this.updateAllCursors(), this.updateAllCursors();
LocalCursorUpdateListener.UPDATE_INTERVAL_MS }, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
); }
public dispose(): void {
clearInterval(this.eventHandle);
} }
private updateAllCursors(): void { private updateAllCursors(): void {
@ -32,7 +31,13 @@ export class LocalCursorUpdateListener {
return; return;
} }
this.lastCursorState = currentCursors; this.lastCursorState = currentCursors;
this.client.updateLocalCursors(currentCursors); this.client
.updateLocalCursors(currentCursors)
.catch((error: unknown) => {
this.client.logger.error(
`Failed to update local cursors: ${error}`
);
});
} }
private getAllCursors(): Record<string, Cursor[]> { private getAllCursors(): Record<string, Cursor[]> {
@ -50,8 +55,4 @@ export class LocalCursorUpdateListener {
}); });
return cursors; return cursors;
} }
public dispose(): void {
clearInterval(this.eventHandle);
}
} }

View file

@ -103,15 +103,19 @@ export const remoteCursorsPlugin = ViewPlugin.fromClass(
} }
); );
export function setCursors(clients: ClientCursors[], app: App) { export function setCursors(clients: ClientCursors[], app: App): void {
cursors = clients.flatMap((client) => { cursors = clients.flatMap((client) => {
return Object.keys(client.cursors).flatMap((path) => const clientCursors = client.cursors;
client.cursors[path]!.map((span) => ({ return Object.keys(clientCursors).flatMap((path) => {
const spans = clientCursors[path];
return spans
? spans.map((span) => ({
name: client.userName, name: client.userName,
path, path,
span span
})) }))
); : [];
});
}); });
app.workspace app.workspace
@ -120,6 +124,7 @@ export function setCursors(clients: ClientCursors[], app: App) {
.filter((view) => view instanceof MarkdownView) .filter((view) => view instanceof MarkdownView)
.forEach((view) => { .forEach((view) => {
// @ts-expect-error, not typed // @ts-expect-error, not typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const editor = view.editor.cm as EditorView; const editor = view.editor.cm as EditorView;
editor.dispatch({ editor.dispatch({

View file

@ -1,4 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorSpan } from "./CursorSpan"; import type { CursorSpan } from "./CursorSpan";
export interface ClientCursors { userName: string, deviceId: string, cursors: Partial<Record<string, CursorSpan[]>>, } export interface ClientCursors {
userName: string;
deviceId: string;
cursors: Partial<Record<string, CursorSpan[]>>;
}

View file

@ -7,4 +7,7 @@ export interface CreateDocumentVersion {
* the server will generate one. If the client provides a document id * the server will generate one. If the client provides a document id
* it must not already exist in the database. * it must not already exist in the database.
*/ */
document_id: string | null, relative_path: string, content: number[], } document_id: string | null;
relative_path: string;
content: number[];
}

View file

@ -1,4 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorSpan } from "./CursorSpan"; import type { CursorSpan } from "./CursorSpan";
export interface CursorPositionFromClient { documentToCursors: Partial<Record<string, CursorSpan[]>>, } export interface CursorPositionFromClient {
documentToCursors: Partial<Record<string, CursorSpan[]>>;
}

View file

@ -1,4 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ClientCursors } from "./ClientCursors"; import type { ClientCursors } from "./ClientCursors";
export interface CursorPositionFromServer { clients: ClientCursors[], } export interface CursorPositionFromServer {
clients: ClientCursors[];
}

View file

@ -1,3 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface CursorSpan { start: number, end: number, } export interface CursorSpan {
start: number;
end: number;
}

View file

@ -1,3 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DeleteDocumentVersion { relativePath: string, } export interface DeleteDocumentVersion {
relativePath: string;
}

View file

@ -5,4 +5,6 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
/** /**
* Response to an update document request. * Response to an update document request.
*/ */
export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion; export type DocumentUpdateResponse =
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
| ({ type: "MergingUpdate" } & DocumentVersion);

View file

@ -1,3 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DocumentVersion { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, } export interface DocumentVersion {
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
contentBase64: string;
isDeleted: boolean;
userId: string;
deviceId: string;
}

View file

@ -1,3 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DocumentVersionWithoutContent { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, } export interface DocumentVersionWithoutContent {
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
isDeleted: boolean;
userId: string;
deviceId: string;
contentSize: number;
}

View file

@ -4,8 +4,10 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
/** /**
* Response to a fetch latest documents request. * Response to a fetch latest documents request.
*/ */
export interface FetchLatestDocumentsResponse { latestDocuments: DocumentVersionWithoutContent[], export interface FetchLatestDocumentsResponse {
latestDocuments: DocumentVersionWithoutContent[];
/** /**
* The update ID of the latest document in the response. * The update ID of the latest document in the response.
*/ */
lastUpdateId: bigint, } lastUpdateId: bigint;
}

View file

@ -7,9 +7,10 @@ export interface PingResponse {
/** /**
* Semantic version of the server. * Semantic version of the server.
*/ */
serverVersion: string, serverVersion: string;
/** /**
* Whether the client is authenticated based on the sent Authorization * Whether the client is authenticated based on the sent Authorization
* header. * header.
*/ */
isAuthenticated: boolean, } isAuthenticated: boolean;
}

View file

@ -1,3 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface SerializedError { errorType: string, message: string, causes: string[], } export interface SerializedError {
errorType: string;
message: string;
causes: string[];
}

View file

@ -1,3 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface UpdateDocumentVersion { parent_version_id: bigint, relative_path: string, content: number[], } export interface UpdateDocumentVersion {
parent_version_id: bigint;
relative_path: string;
content: number[];
}

View file

@ -2,4 +2,6 @@
import type { CursorPositionFromClient } from "./CursorPositionFromClient"; import type { CursorPositionFromClient } from "./CursorPositionFromClient";
import type { WebSocketHandshake } from "./WebSocketHandshake"; import type { WebSocketHandshake } from "./WebSocketHandshake";
export type WebSocketClientMessage = { "type": "handshake" } & WebSocketHandshake | { "type": "cursorPositions" } & CursorPositionFromClient; export type WebSocketClientMessage =
| ({ type: "handshake" } & WebSocketHandshake)
| ({ type: "cursorPositions" } & CursorPositionFromClient);

View file

@ -1,3 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface WebSocketHandshake { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, } export interface WebSocketHandshake {
token: string;
deviceId: string;
lastSeenVaultUpdateId: number | null;
}

View file

@ -2,4 +2,6 @@
import type { CursorPositionFromServer } from "./CursorPositionFromServer"; import type { CursorPositionFromServer } from "./CursorPositionFromServer";
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer; export type WebSocketServerMessage =
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
| ({ type: "cursorPositions" } & CursorPositionFromServer);

View file

@ -1,4 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
export interface WebSocketVaultUpdate { documents: DocumentVersionWithoutContent[], isInitialSync: boolean, } export interface WebSocketVaultUpdate {
documents: DocumentVersionWithoutContent[];
isInitialSync: boolean;
}

View file

@ -275,7 +275,9 @@ export class SyncClient {
}); });
} }
public async updateLocalCursors(documentToCursors: Record<RelativePath, CursorSpan[]>): Promise<void> { public async updateLocalCursors(
documentToCursors: Record<RelativePath, CursorSpan[]>
): Promise<void> {
this.webSocketManager.updateLocalCursors({ documentToCursors }); this.webSocketManager.updateLocalCursors({ documentToCursors });
} }