Skip to content

Instantly share code, notes, and snippets.

@alexandresalome
Created March 2, 2025 07:59
Show Gist options
  • Save alexandresalome/ab8b24b335cdd2dadc61b323c1cdc001 to your computer and use it in GitHub Desktop.
Save alexandresalome/ab8b24b335cdd2dadc61b323c1cdc001 to your computer and use it in GitHub Desktop.
import {Plugin, PluginKey, Transaction} from "prosemirror-state";
import {Decoration, DecorationSet} from "prosemirror-view";
import {Mappable, Step, StepResult} from "prosemirror-transform";
import {Node, Schema} from "prosemirror-model";
export const cursorPluginKey = new PluginKey<PluginState>("cursorPlugin");
export interface CursorPosition {
clientID: string | number;
from: number;
to: number;
}
export interface PluginState {
clientID: string | number;
from: number;
to: number;
cursors: CursorPosition[];
}
interface PluginMeta {
cursors: CursorPosition[];
}
class CursorStep extends Step {
constructor(
public readonly clientID: string | number,
public readonly from: number,
public readonly to: number,
) {
super();
}
apply(doc: Node): StepResult {
return StepResult.ok(doc);
}
invert(): Step {
return this;
}
map(mapping: Mappable): Step | null {
return new CursorStep(
this.clientID,
mapping.map(this.from),
mapping.map(this.to, -1),
);
}
toJSON() {
return {stepType: "cursor", clientID: this.clientID, from: this.from, to: this.to};
}
static fromJSON(_: Schema, json: {clientID: string | number, from: number, to: number}) {
return new CursorStep(
json.clientID,
json.from,
json.to,
);
}
}
Step.jsonID("cursor", CursorStep);
export function cursorCollab(clientID: string|number){
return new Plugin<PluginState>({
key: cursorPluginKey,
appendTransaction(transactions, oldState, newState) {
// Add cursor steps to transactions if they don't already exist
const pluginState = cursorPluginKey.getState(oldState);
if (!pluginState) {
return;
}
// Keep the last selection set transaction
let selectionSetTr: Transaction | null = null;
for (const tr of transactions) {
if (tr.selectionSet) {
selectionSetTr = tr;
}
}
if (!selectionSetTr) {
return null;
}
const {from, to} = selectionSetTr.selection;
if (pluginState.from === from && pluginState.to === to) {
return null;
}
const step = new CursorStep(clientID, from, to);
return newState.tr.step(step);
},
state: {
init() {
return {
clientID,
from: 0,
to: 0,
cursors: [],
} as PluginState;
},
apply(tr, value) {
const meta: PluginMeta | undefined = tr.getMeta(cursorPluginKey);
if (meta) {
return {
...value,
cursors: meta.cursors,
};
}
let clientCursor: CursorPosition | null = null;
const allCursors = [...value.cursors];
const replaceInCursors = (cursor: CursorPosition) => {
const index = allCursors.findIndex(c => c.clientID === cursor.clientID);
if (index !== -1) {
allCursors[index] = cursor;
} else {
allCursors.push(cursor);
}
}
const applyStep = (step: Step) => {
const map = step.getMap();
for (let i = 0; i < allCursors.length; i++) {
const cursor = allCursors[i];
cursor.from = map.map(cursor.from);
cursor.to = map.map(cursor.to);
}
if (clientCursor) {
clientCursor.from = map.map(clientCursor.from);
clientCursor.to = map.map(clientCursor.to);
}
}
for (const step of tr.steps) {
applyStep(step);
if (step instanceof CursorStep) {
if (clientID === step.clientID) {
clientCursor = {
clientID: step.clientID,
from: step.from,
to: step.to,
};
replaceInCursors(clientCursor);
} else {
replaceInCursors({
clientID: step.clientID,
from: step.from,
to: step.to,
});
}
}
}
if (!clientCursor) {
clientCursor = {
clientID,
from: value.from,
to: value.to,
};
}
return {
clientID,
from: clientCursor.from,
to: clientCursor.to,
cursors: allCursors,
};
},
},
props: {
decorations(state) {
const pluginState = cursorPluginKey.getState(state);
const decorations: Decoration[] = [];
pluginState?.cursors.forEach(cursor => {
if (cursor.clientID === clientID) return; // Skip own cursor
if (cursor.from === cursor.to) {
// Single cursor position (not a selection)
decorations.push(
Decoration.widget(cursor.from, () => {
const cursorElement = document.createElement("span");
cursorElement.classList.add(`cursor-${cursor.clientID || 'unknown'}`);
return cursorElement;
}, {
key: `cursor-${cursor.clientID || 'unknown'}`,
side: 1,
}) // render after the position, so that typing on the current position doesn't move the remote cursor.
);
return;
}
// select, show as text
decorations.push(
Decoration.inline(cursor.from, cursor.to, {
class: `selection-${cursor.clientID || 'unknown'}`,
nodeName: "span",
}, {
key: `selection-${cursor.clientID || 'unknown'}`
})
);
});
return DecorationSet.create(state.doc, decorations);
}
}
});
}
@alexandresalome
Copy link
Author

Used for a ProseMirror thread

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment