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
4 changed files with 234 additions and 2 deletions
Showing only changes of commit 5ce6143838 - Show all commits

View file

@ -16,7 +16,10 @@ 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/remote-cursors/remote-cursor-theme";
import { remoteCursorsPlugin } from "./views/remote-cursors/remote-cursors-plugin";
const MIN_WAIT_BETWEEN_UPDATES_IN_MS = 250;
export default class VaultLinkPlugin extends Plugin {
private readonly disposables: (() => unknown)[] = [];
private settingsTab: SyncSettingsTab | undefined;
@ -61,18 +64,23 @@ 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.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 +189,7 @@ export default class VaultLinkPlugin extends Plugin {
this.client.syncLocallyUpdatedFile({
relativePath: path
}),
250
MIN_WAIT_BETWEEN_UPDATES_IN_MS
)
);
}

View file

@ -0,0 +1,67 @@
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"
}
});

View file

@ -0,0 +1,47 @@
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 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: "SelectionCaret",
attr: {
style: `background-color: ${this.color}; border-color: ${this.color}`
}
},
(span) => {
span.appendText("\u2060");
span.createEl("div", {
cls: "SelectionCaretDot"
});
span.appendText("\u2060");
span.createEl("div", {
cls: "SelectionInfo",
text: this.name
});
span.appendText("\u2060");
}
);
}
public eq(other: RemoteCursorWidget) {
return other.color === this.color && other.name === this.name;
}
}

View file

@ -0,0 +1,110 @@
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<Range<Decoration>> = [];
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
}
);