Created
March 2, 2025 07:59
-
-
Save alexandresalome/ab8b24b335cdd2dadc61b323c1cdc001 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
}); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Used for a ProseMirror thread