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
|
||||
}
|
||||
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 {
|
||||
EditorView,
|
||||
ViewUpdate,
|
||||
ViewPlugin,
|
||||
Decoration,
|
||||
WidgetType
|
||||
} from "@codemirror/view";
|
||||
|
||||
import type { PluginValue, DecorationSet } from "@codemirror/view";
|
||||
import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view";
|
||||
|
||||
export class RemoteCursorWidget extends WidgetType {
|
||||
public constructor(
|
||||
|
|
@ -21,27 +20,27 @@ export class RemoteCursorWidget extends WidgetType {
|
|||
return editor.contentDOM.createEl(
|
||||
"span",
|
||||
{
|
||||
cls: "SelectionCaret",
|
||||
cls: "selection-caret",
|
||||
attr: {
|
||||
style: `background-color: ${this.color}; border-color: ${this.color}`
|
||||
}
|
||||
},
|
||||
(span) => {
|
||||
span.appendText("\u2060");
|
||||
span.createEl("div", {
|
||||
cls: "SelectionCaretDot"
|
||||
cls: "stick"
|
||||
});
|
||||
span.appendText("\u2060");
|
||||
span.createEl("div", {
|
||||
cls: "SelectionInfo",
|
||||
cls: "dot"
|
||||
});
|
||||
span.createEl("div", {
|
||||
cls: "info",
|
||||
text: this.name
|
||||
});
|
||||
span.appendText("\u2060");
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public eq(other: RemoteCursorWidget) {
|
||||
public eq(other: RemoteCursorWidget): boolean {
|
||||
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