diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index 1e79be35..efd54417 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -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 ) ); } 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 new file mode 100644 index 00000000..373ec9f1 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-theme.ts @@ -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" + } +}); diff --git a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-widget.ts b/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-widget.ts new file mode 100644 index 00000000..767311ea --- /dev/null +++ b/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-widget.ts @@ -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; + } +} 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 new file mode 100644 index 00000000..52e2d5e0 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursors-plugin.ts @@ -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> = []; + + 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 + } +);