Refine cursor look
This commit is contained in:
parent
f4c77ddd25
commit
deca4c4dc2
6 changed files with 201 additions and 187 deletions
|
|
@ -5,5 +5,5 @@ export function getRandomColor(name: string): string {
|
||||||
hash |= 0; // Convert to 32bit integer
|
hash |= 0; // Convert to 32bit integer
|
||||||
}
|
}
|
||||||
const normalised = hash / 0x7fffffff;
|
const normalised = hash / 0x7fffffff;
|
||||||
return `hsl(${Math.abs(normalised * 360)}, 70%, 30%)`; // HSL color
|
return `hsl(${Math.abs(normalised * 360)}, 55%, 55%)`; // HSL color
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state";
|
import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state";
|
||||||
import {
|
import {
|
||||||
EditorView,
|
|
||||||
ViewUpdate,
|
ViewUpdate,
|
||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
Decoration,
|
Decoration,
|
||||||
WidgetType
|
WidgetType
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
|
|
||||||
import type { PluginValue, DecorationSet } from "@codemirror/view";
|
import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view";
|
||||||
|
|
||||||
export class RemoteCursorWidget extends WidgetType {
|
export class RemoteCursorWidget extends WidgetType {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
|
@ -21,27 +20,27 @@ export class RemoteCursorWidget extends WidgetType {
|
||||||
return editor.contentDOM.createEl(
|
return editor.contentDOM.createEl(
|
||||||
"span",
|
"span",
|
||||||
{
|
{
|
||||||
cls: "SelectionCaret",
|
cls: "selection-caret",
|
||||||
attr: {
|
attr: {
|
||||||
style: `background-color: ${this.color}; border-color: ${this.color}`
|
style: `background-color: ${this.color}; border-color: ${this.color}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(span) => {
|
(span) => {
|
||||||
span.appendText("\u2060");
|
|
||||||
span.createEl("div", {
|
span.createEl("div", {
|
||||||
cls: "SelectionCaretDot"
|
cls: "stick"
|
||||||
});
|
});
|
||||||
span.appendText("\u2060");
|
|
||||||
span.createEl("div", {
|
span.createEl("div", {
|
||||||
cls: "SelectionInfo",
|
cls: "dot"
|
||||||
|
});
|
||||||
|
span.createEl("div", {
|
||||||
|
cls: "info",
|
||||||
text: this.name
|
text: this.name
|
||||||
});
|
});
|
||||||
span.appendText("\u2060");
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public eq(other: RemoteCursorWidget) {
|
public eq(other: RemoteCursorWidget): boolean {
|
||||||
return other.color === this.color && other.name === this.name;
|
return other.color === this.color && other.name === this.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
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) {
|
||||||
|
cursors = clients.flatMap((client) => {
|
||||||
|
return Object.keys(client.cursors).flatMap((path) =>
|
||||||
|
client.cursors[path]!.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
|
||||||
|
const editor = view.editor.cm as EditorView;
|
||||||
|
|
||||||
|
editor.dispatch({
|
||||||
|
effects: [forceUpdate.of(null)]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue