discourse-shared-edits/assets/javascripts/discourse/lib/cursor-overlay.js
Sam 50d9317272
FEATURE: yjs-based collaborative editing with state recovery and cursor sync
Major overhaul of the shared edits plugin to improve reliability, robustness,
and developer experience:

**Backend**
- Extract Revise service for cleaner controller orchestration
- Add StateValidator for base64/Yjs safety, corruption detection, and recovery
- Centralize protocol constants (Ruby + JS) to avoid hardcoded strings
- Add state hash sync verification and vector validation endpoints
- Harden security (guardian checks), error handling, and resource cleanup
- Resize shared edit columns migration; add state_hash column

**Frontend**
- Decompose shared-edit-manager into focused modules: yjs-document,
  markdown-sync, rich-mode-sync, network-manager, encoding-utils
- Add cursor overlay and caret coordinate tracking for selection sharing
- Add ProseMirror extension for rich-mode collaborative editing
- Cache-busted Yjs bundle loading via hashed filenames
- Fix scroll drift during sync

**Testing & Tooling**
- Extensive new specs: state_validator, revision_controller, model, revise service
- New Ember acceptance tests: cursor, lifecycle, sync flows
- Add support scripts: fake_writer (Playwright), state_corruptor, debug_recovery
- Add support/lint wrapper for full CI lint suite
- Update dependencies and rebuild Yjs/y-prosemirror bundles
2026-02-13 11:34:52 +11:00

229 lines
6.1 KiB
JavaScript

import getCaretCoordinates from "../lib/caret-coordinates";
export default class CursorOverlay {
constructor(textarea) {
this.textarea = textarea;
this.container = document.createElement("div");
this.container.className = "shared-edits-cursor-overlay";
this.updateContainerPosition();
const parent = textarea.parentElement;
if (getComputedStyle(parent).position === "static") {
parent.style.position = "relative";
}
parent.appendChild(this.container);
this.cursors = new Map();
this.activeTypists = new Map();
this.boundOnScroll = this.onScroll.bind(this);
this.textarea.addEventListener("scroll", this.boundOnScroll, {
passive: true,
});
this.resizeObserver = new ResizeObserver(() => {
this.updateContainerPosition();
this.refresh();
});
this.resizeObserver.observe(textarea);
}
updateContainerPosition() {
this.container.style.top = `${this.textarea.offsetTop}px`;
this.container.style.left = `${this.textarea.offsetLeft}px`;
this.container.style.width = `${this.textarea.offsetWidth}px`;
this.container.style.height = `${this.textarea.offsetHeight}px`;
}
onScroll() {
this.cursors.forEach((cursor) => this.renderCursor(cursor));
}
refresh() {
const Y = window.Y;
if (!Y || !Y.createAbsolutePositionFromRelativePosition) {
return;
}
this.cursors.forEach((cursor) => {
if (cursor.relativePosition && cursor.doc) {
const absolutePosition = Y.createAbsolutePositionFromRelativePosition(
cursor.relativePosition,
cursor.doc
);
if (absolutePosition) {
this.calculateCursorPosition(cursor, absolutePosition.index);
}
}
});
}
updateCursor(clientId, origin, relativePosition, doc) {
const Y = window.Y;
if (!Y || !Y.createAbsolutePositionFromRelativePosition) {
return;
}
let cursor = this.cursors.get(clientId);
if (cursor && cursor.user.username !== origin.username) {
cursor.element.remove();
this.cursors.delete(clientId);
const typist = this.activeTypists.get(clientId);
if (typist?.timeout) {
clearTimeout(typist.timeout);
}
this.activeTypists.delete(clientId);
cursor = null;
}
const isNew = !cursor;
if (isNew) {
cursor = this.createCursorElement({
user_id: origin.user_id,
username: origin.username,
});
this.cursors.set(clientId, cursor);
}
cursor.clientId = clientId;
cursor.relativePosition = relativePosition;
cursor.origin = origin;
cursor.doc = doc;
const absolutePosition = Y.createAbsolutePositionFromRelativePosition(
relativePosition,
doc
);
if (absolutePosition) {
this.markTypist(clientId);
this.calculateCursorPosition(cursor, absolutePosition.index);
}
if (isNew) {
this.container.appendChild(cursor.element);
}
}
markTypist(clientId) {
const now = Date.now();
const typist = this.activeTypists.get(clientId) || {};
if (typist.timeout) {
clearTimeout(typist.timeout);
}
typist.lastTyped = now;
typist.timeout = setTimeout(() => {
const cursor = this.cursors.get(clientId);
if (cursor) {
cursor.element.style.display = "none";
}
}, 5000);
this.activeTypists.set(clientId, typist);
}
calculateCursorPosition(cursor, index) {
const typist = this.activeTypists.get(cursor.clientId);
const isActive = typist && Date.now() - typist.lastTyped < 5000;
if (!isActive) {
cursor.element.style.display = "none";
return;
}
const viewCoords = this.getViewCoords(index);
if (viewCoords) {
cursor.absoluteTop = viewCoords.top;
cursor.absoluteLeft = viewCoords.left;
cursor.height = viewCoords.height;
const isFirstLine = viewCoords.top < (viewCoords.height || 20) * 1.2;
if (isFirstLine) {
cursor.label.classList.add("shared-edits-cursor__label--bottom");
} else {
cursor.label.classList.remove("shared-edits-cursor__label--bottom");
}
cursor.element.style.display = "block";
this.renderCursor(cursor);
} else {
cursor.element.style.display = "none";
}
}
renderCursor(cursor) {
const top = cursor.absoluteTop - this.textarea.scrollTop;
const left = cursor.absoluteLeft - this.textarea.scrollLeft;
cursor.element.style.transform = `translate(${left}px, ${top}px)`;
if (cursor.height) {
cursor.element.style.height = `${cursor.height}px`;
}
}
getViewCoords(index) {
return getCaretCoordinates(this.textarea, index);
}
createCursorElement(user) {
const el = document.createElement("div");
el.className = "shared-edits-cursor";
const color = this.getColor(user.user_id);
el.style.borderColor = color;
const label = document.createElement("div");
label.className = "shared-edits-cursor__label";
label.textContent = user.username;
label.style.backgroundColor = color;
el.appendChild(label);
return { element: el, label, user };
}
removeCursor(clientId) {
const cursor = this.cursors.get(clientId);
if (cursor) {
cursor.element.remove();
this.cursors.delete(clientId);
}
const typist = this.activeTypists.get(clientId);
if (typist) {
if (typist.timeout) {
clearTimeout(typist.timeout);
}
this.activeTypists.delete(clientId);
}
}
clearPosition(clientId) {
const cursor = this.cursors.get(clientId);
if (cursor) {
cursor.relativePosition = null;
cursor.element.style.display = "none";
}
}
getColor(id) {
const index = (id || 0) % 7;
return `var(--shared-edit-color-${index + 1})`;
}
destroy() {
this.textarea.removeEventListener("scroll", this.boundOnScroll);
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
this.container.remove();
this.cursors.clear();
this.activeTypists.forEach((typist) => {
if (typist.timeout) {
clearTimeout(typist.timeout);
}
});
this.activeTypists.clear();
}
}