Fix main & improve cursor sync (#101)

This commit is contained in:
Andras Schmelczer 2025-08-25 17:15:52 +01:00 committed by GitHub
parent 81b81e30ff
commit a36a24effc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 926 additions and 686 deletions

View file

@ -34,6 +34,7 @@
"url": "^0.11.4",
"virtual-scroller": "^1.13.1",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
"webpack-cli": "^6.0.1",
"reconcile-text": "^0.5.0"
}
}

View file

@ -24,15 +24,18 @@ export function flakyWebSocketFactory(
public set onmessage(callback: (event: MessageEvent) => void) {
super.onmessage = async (event: MessageEvent): Promise<void> => {
await this.locks.waitForLock(FlakyWebSocket.RECEIVE_KEY);
await this.locks.withLock(
FlakyWebSocket.RECEIVE_KEY,
async () => {
if (jitterScaleInSeconds > 0) {
await sleep(
Math.random() * jitterScaleInSeconds * 1000
);
}
if (jitterScaleInSeconds > 0) {
await sleep(Math.random() * jitterScaleInSeconds * 1000);
}
callback(event);
this.locks.unlock(FlakyWebSocket.RECEIVE_KEY);
callback(event);
}
);
};
}
@ -66,15 +69,12 @@ export function flakyWebSocketFactory(
data: string | ArrayBufferLike | Blob | ArrayBufferView
): Promise<void> {
// maintain message order
await this.locks.waitForLock(FlakyWebSocket.SEND_KEY);
if (jitterScaleInSeconds > 0) {
await sleep(Math.random() * jitterScaleInSeconds * 1000);
}
super.send(data);
this.locks.unlock(FlakyWebSocket.SEND_KEY);
await this.locks.withLock(FlakyWebSocket.SEND_KEY, async () => {
if (jitterScaleInSeconds > 0) {
await sleep(Math.random() * jitterScaleInSeconds * 1000);
}
super.send(data);
});
}
} as unknown as typeof WebSocket;
}

View file

@ -19,7 +19,7 @@ import { updateEditorStatusDisplay } from "./views/editor-sync-line/editor-sync-
import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme";
import {
remoteCursorsPlugin,
setCursors
RemoteCursorsPluginValue
} from "./views/cursors/remote-cursors-plugin";
import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener";
import { slowFetchFactory } from "./debugging/slow-fetch-factory";
@ -93,7 +93,7 @@ export default class VaultLinkPlugin extends Plugin {
this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]);
this.client.addRemoteCursorsUpdateListener((cursors) => {
setCursors(cursors, this.app);
RemoteCursorsPluginValue.setCursors(cursors, this.app);
});
const cursorListener = new LocalCursorUpdateListener(

View file

@ -9,104 +9,201 @@ import type {
ViewUpdate
} from "@codemirror/view";
import { RemoteCursorWidget } from "./remote-cursor-widget";
import type {
CursorSpan,
DocumentWithMaybeOutdatedClientCursors
} from "sync-client";
import type { CursorSpan, MaybeOutdatedClientCursors } from "sync-client";
import type { App } from "obsidian";
import { MarkdownView } from "obsidian";
let cursors: {
name: string;
path: string;
span: CursorSpan;
deviceId: string;
}[] = [];
import { StateEffect } from "@codemirror/state";
import { getRandomColor } from "src/utils/get-random-color";
import { updateSelection } from "./update-selection";
import type { SpanWithHistory } from "reconcile-text";
import { reconcileWithHistory } from "reconcile-text";
function findWhereToMoveCursor(
cursor: number,
spans: SpanWithHistory[]
): number | null {
let position = 0;
for (const span of spans) {
// left and origin are the same
if (position === cursor && span.history === "AddedFromRight") {
return position + span.text.length;
}
position += span.text.length;
if (position === cursor && span.history === "RemovedFromRight") {
return position - span.text.length;
}
}
return null;
}
const forceUpdate = StateEffect.define();
export class RemoteCursorsPluginValue implements PluginValue {
private static cursors: {
name: string;
path: string;
span: CursorSpan;
deviceId: string;
isOutdated: boolean;
}[] = [];
public decorations: DecorationSet = RangeSet.of([]);
public update(update: ViewUpdate): void {
update.changes.iterChanges((fromA, toA, fromB, toB, _inserted) => {
const spans = cursors.map((cursor) => cursor.span);
updateSelection({
fromA,
toA,
fromB,
toB,
spans
public static setCursors(
clients: MaybeOutdatedClientCursors[],
app: App
): void {
RemoteCursorsPluginValue.cursors = [
...RemoteCursorsPluginValue.cursors.filter(({ deviceId }) =>
clients.some(
(client) =>
client.deviceId === deviceId && client.isOutdated
)
),
...clients
.filter(
({ isOutdated, deviceId }) =>
!isOutdated ||
RemoteCursorsPluginValue.cursors.every(
(c) => deviceId !== c.deviceId
)
)
.flatMap((client) => {
const clientCursors = client.documentsWithCursors;
return clientCursors.flatMap((cursor) =>
cursor.cursors.map((span) => ({
name: client.userName,
path: cursor.relative_path,
deviceId: client.deviceId,
isOutdated: client.isOutdated,
span: { ...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)]
});
});
}
public update(update: ViewUpdate): void {
const original = update.startState.doc.toString();
const edited = update.state.doc.toString();
const updatedPositions: number[] = [];
const reconciled = reconcileWithHistory(
original,
{
text: original,
cursors: RemoteCursorsPluginValue.cursors.flatMap(
({ span }, i) => [
{ id: i * 2, position: span.start },
{ id: i * 2 + 1, position: span.end }
]
)
},
edited,
"Character"
);
reconciled.cursors.forEach(({ id, position }) => {
const whereToJump = findWhereToMoveCursor(
position,
reconciled.history
);
if (whereToJump !== null) {
updatedPositions[id] = whereToJump;
} else {
updatedPositions[id] = position;
}
});
RemoteCursorsPluginValue.cursors.forEach(({ span }, i) => {
span.start = updatedPositions[i * 2];
span.end = updatedPositions[i * 2 + 1];
});
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);
RemoteCursorsPluginValue.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};`
};
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);
if (startLine.number === endLine.number) {
// selected content in a single line.
decorations.push({
from: currentLine.from,
to: currentLine.to,
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
})
});
}
// render text-selection in the last line
decorations.push({
from: endLine.from,
from: end,
to: end,
value: Decoration.mark({
attributes
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)
})
});
}
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);
}
@ -118,43 +215,3 @@ export const remoteCursorsPlugin = ViewPlugin.fromClass(
decorations: (v) => v.decorations
}
);
export function setCursors(
clients: DocumentWithMaybeOutdatedClientCursors[],
app: App
): void {
cursors = [
...cursors.filter(({ deviceId }) =>
clients.some(
(client) => client.deviceId === deviceId && client.isOutdated
)
),
...clients
.filter(({ isOutdated }) => !isOutdated)
.flatMap((client) => {
const clientCursors = client.documentsWithCursors;
return clientCursors.flatMap((cursor) =>
cursor.cursors.map((span) => ({
name: client.userName,
path: cursor.relative_path,
deviceId: client.deviceId,
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

@ -1,111 +0,0 @@
import { updateSelection } from "./update-selection";
describe("Selection update", () => {
it("should handle span fully before - insert", () => {
const spans = [{ start: 3, end: 5 }];
updateSelection({
fromA: 0,
toA: 0,
fromB: 0,
toB: 2,
spans
});
expect(spans).toEqual([{ start: 5, end: 7 }]);
});
it("should handle span fully before - delete", () => {
const spans = [{ start: 3, end: 5 }];
updateSelection({
fromA: 0,
toA: 2,
fromB: 0,
toB: 0,
spans
});
expect(spans).toEqual([{ start: 1, end: 3 }]);
});
it("should handle span fully after - insert", () => {
const spans = [{ start: 3, end: 5 }];
updateSelection({
fromA: 6,
toA: 6,
fromB: 6,
toB: 10,
spans
});
expect(spans).toEqual([{ start: 3, end: 5 }]);
});
it("should handle span fully after - delete", () => {
const spans = [{ start: 3, end: 5 }];
updateSelection({
fromA: 6,
toA: 10,
fromB: 6,
toB: 6,
spans
});
expect(spans).toEqual([{ start: 3, end: 5 }]);
});
it("should handle span fully within - insert", () => {
const spans = [{ start: 3, end: 5 }];
updateSelection({
fromA: 4,
toA: 4,
fromB: 4,
toB: 6,
spans
});
expect(spans).toEqual([{ start: 3, end: 7 }]);
});
it("should handle span fully within - delete", () => {
const spans = [{ start: 3, end: 5 }];
updateSelection({
fromA: 4,
toA: 5,
fromB: 4,
toB: 4,
spans
});
expect(spans).toEqual([{ start: 3, end: 4 }]);
});
it("should handle span overlapping with start", () => {
const spans = [{ start: 3, end: 5 }];
updateSelection({
fromA: 2,
toA: 4,
fromB: 2,
toB: 2,
spans
});
expect(spans).toEqual([{ start: 2, end: 4 }]);
});
it("should handle span overlapping with end", () => {
const spans = [{ start: 3, end: 5 }];
updateSelection({
fromA: 4,
toA: 6,
fromB: 4,
toB: 4,
spans
});
expect(spans).toEqual([{ start: 3, end: 4 }]);
});
it("delete entire selection", () => {
const spans = [{ start: 3, end: 5 }];
updateSelection({
fromA: 0,
toA: 10,
fromB: 0,
toB: 0,
spans
});
expect(spans).toEqual([{ start: 0, end: 0 }]);
});
});

View file

@ -1,40 +0,0 @@
import type { CursorSpan } from "sync-client";
export const updateSelection = ({
fromA,
toA,
toB,
spans
}: {
fromA: number;
toA: number;
fromB: number;
toB: number;
spans: CursorSpan[];
}): void => {
spans.forEach((span) => {
if (fromA <= span.start) {
// the change covers the entirety of the selection
if (toA > span.end) {
span.start = toB;
span.end = toB;
return;
}
let change = toB - toA;
if (change < 0) {
// it's a deletion
// if overlaps with the start, we can't move it back more than the deleted range
change = Math.max(change, fromA - span.start);
}
span.start += change;
span.end += change;
} else if (toA <= span.end) {
span.end += toB - toA;
} else if (toB <= span.end) {
// a deletion overlaps with the end, so we move the end
span.end = toB;
}
});
};

View file

@ -1,7 +1,7 @@
import type { Workspace } from "obsidian";
import { FileView, setIcon } from "obsidian";
import type { SyncClient } from "sync-client";
import { DocumentUpdateStatus } from "sync-client";
import { DocumentSyncStatus } from "sync-client";
import "./editor-sync-line.scss";
export function updateEditorStatusDisplay(
@ -35,7 +35,7 @@ export function updateEditorStatusDisplay(
const isLoading =
client.getDocumentSyncingStatus(filePath) ==
DocumentUpdateStatus.SYNCING;
DocumentSyncStatus.SYNCING;
if (isLoading) {
element.classList.add("loading");