All files / collaborative-prosemirror/src/presence plugin.ts

12.6% Statements 15/119
0% Branches 0/51
0% Functions 0/14
13.72% Lines 14/102

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 2159x 9x   9x 9x 9x 9x 9x 9x                   9x                                                                       9x                                                                                                                     9x                                                                                                                                                     9x   9x                     9x                          
import {Plugin, PluginKey} from 'prosemirror-state';
import {Decoration, DecorationSet} from 'prosemirror-view';
import {JsonCrdtDataType} from 'json-joy/lib/json-crdt-patch/constants';
import {peritext as peritextPresence} from '@jsonjoy.com/collaborative-presence';
import {SYNC_PLUGIN_KEY, TransactionOrigin} from '../constants';
import {pmPosToPoint, pointToPmPos} from '../util';
import {UserPresenceIdx} from '@jsonjoy.com/collaborative-presence';
import * as view from './view';
import {CursorManager} from './view';
import type {PresenceManager, PresenceEvent, PeerEntry} from '@jsonjoy.com/collaborative-presence/lib/PresenceManager';
import type {RgaSelection, UserPresence} from '@jsonjoy.com/collaborative-presence/lib/types';
import type {StablePeritextSelection} from '@jsonjoy.com/collaborative-presence/lib/peritext';
import type {PeritextRef} from '@jsonjoy.com/collaborative-peritext';
import type {EditorView, DecorationAttrs} from 'prosemirror-view';
import type {EditorState} from 'prosemirror-state';
import type {Peritext} from 'json-joy/lib/json-crdt-extensions';
import type {SyncPluginTransactionMeta} from '../sync/types';
 
const PRESENCE_PLUGIN_KEY = new PluginKey<DecorationSet>('jsonjoy.com/json-crdt/presence');
 
export interface PresencePluginOpts<Meta extends object = object> {
  /** The shared presence store. */
  manager: PresenceManager<Meta>;
  /** Accessor for the Peritext CRDT. */
  peritext: PeritextRef;
  /** Custom caret DOM factory. When omitted, the default label-style cursor
   * from `presence-styles.ts` is used. */
  renderCursor?: CursorRenderer<Meta>;
  /** Custom inline decoration attrs factory for selection highlights. When
   * omitted, a semi-transparent background is used. */
  renderSelection?: SelectionRenderer;
  /** Extracts a {@link PresenceUser} (name, color) from the `meta` payload of a
   * `UserPresence` tuple. When omitted, user info is not shown on carets. */
  userFromMeta?: (meta: Meta) => view.PresenceUser | undefined;
  /** Milliseconds after which the name label is faded (default 3000). */
  fadeAfterMs?: number;
  /** Milliseconds of inactivity after which the caret is dimmed (default 30000). */
  dimAfterMs?: number;
  /** Milliseconds of inactivity after which the selection highlight is hidden
   * and the caret is dimmed (default 60000). */
  hideAfterMs?: number;
  /** Interval in milliseconds for running {@link PresenceManager.removeOutdated}.
   * Pass `0` to disable internal GC. Default: 5000. */
  gcIntervalMs?: number;
}
 
export type CursorRenderer<Meta extends object = object> = (
  peerId: string,
  user: view.PresenceUser | undefined,
  opts: PresencePluginOpts<Meta>,
) => HTMLElement;
 
export type SelectionRenderer = (peerId: string, user?: view.PresenceUser) => DecorationAttrs;
 
export const createPlugin = <Meta extends object = object>(opts: PresencePluginOpts<Meta>): Plugin<DecorationSet> => {
  const {manager, peritext, gcIntervalMs = 5_000} = opts;
 
  // Shared cursor DOM cache — lives for the lifetime of the plugin so that
  // CSS animations survive decoration rebuilds.
  const cursorManager = new CursorManager<Meta>();
 
  return new Plugin<DecorationSet>({
    key: PRESENCE_PLUGIN_KEY,
    state: {
      init(_, state) {
        return buildDecorations(state, opts, cursorManager);
      },
      apply(tr, prevDecorations, _oldState, newState) {
        const syncMeta = tr.getMeta(SYNC_PLUGIN_KEY) as SyncPluginTransactionMeta | undefined;
        if (syncMeta?.orig === TransactionOrigin.REMOTE) return buildDecorations(newState, opts, cursorManager);
        const presenceMeta = tr.getMeta(PRESENCE_PLUGIN_KEY);
        if (presenceMeta?.presenceUpdated) return buildDecorations(newState, opts, cursorManager);
        // Local edit — efficiently remap through the mapping.
        return prevDecorations.map(tr.mapping, tr.doc);
      },
    },
    props: {
      decorations(state) {
        return PRESENCE_PLUGIN_KEY.getState(state);
      },
    },
    view(view: EditorView) {
      const unsubscribe = manager.onChange.listen((_evt: PresenceEvent) => {
        const tr = view.state.tr;
        tr.setMeta(PRESENCE_PLUGIN_KEY, {presenceUpdated: true});
        view.dispatch(tr);
      });
      let gcTimer: unknown;
      if (gcIntervalMs > 0) gcTimer = setInterval(() => manager.removeOutdated(opts.fadeAfterMs), gcIntervalMs);
      return {
        update(view, prevState) {
          const docChanged = !prevState.doc.eq(view.state.doc);
          const selectionChanged = !prevState.selection.eq(view.state.selection);
          const doSendPresence = docChanged || selectionChanged;
          if (doSendPresence) {
            const dto = buildLocalPresenceDto(view, peritext);
            if (dto) manager.setSelections([dto]);
          }
        },
        destroy() {
          unsubscribe();
          clearInterval(gcTimer as any);
          cursorManager.destroy();
        },
      };
    },
  });
};
 
/**
 * Build a `DecorationSet` with widget (caret) and inline (selection highlight)
 * decorations for every remote peer tracked by the {@link PresenceManager}.
 */
const buildDecorations = <Meta extends object>(
  state: EditorState,
  opts: PresencePluginOpts<Meta>,
  cursorMgr: CursorManager<Meta>,
): DecorationSet => {
  const {
    manager,
    peritext: peritextRef,
    renderCursor = view.renderCursor,
    renderSelection = view.renderSelection,
    userFromMeta,
    hideAfterMs = 60_000,
  } = opts;
  const localProcessId = manager.getProcessId();
  const decorations: Decoration[] = [];
  const api = peritextRef();
  if (!api) return DecorationSet.create(state.doc, []);
  const txt: Peritext = api.txt;
  const rootBlock = txt.blocks.root;
  const doc = state.doc;
  const maxPos = Math.max(doc.content.size - 1, 0);
  const now = Date.now();
  const peers = manager.peers;
  const activePeerIds = new Set<string>();
  for (const processId in peers) {
    if (processId === localProcessId) continue;
    const entry: PeerEntry<Meta> = peers[processId];
    const presence: UserPresence<Meta> = entry[0];
    const receivedAt: number = entry[1];
    const age = now - receivedAt;
    const hide = age >= hideAfterMs;
    if (hide) continue;
    const selections: unknown[] = presence[UserPresenceIdx.Selections] as unknown[];
    if (!selections) continue;
    const meta = presence[UserPresenceIdx.Meta];
    const user: view.PresenceUser | undefined = userFromMeta ? userFromMeta(meta) : undefined;
    for (const sel of selections) {
      if (!isRgaSelection(sel)) continue;
      let stableSelections: StablePeritextSelection[];
      try {
        stableSelections = peritextPresence.fromDto(txt, sel);
      } catch {
        continue;
      }
      if (!stableSelections.length) continue;
      for (const [range, startIsAnchor] of stableSelections) {
        const anchorPoint = startIsAnchor ? range.start : range.end;
        const focusPoint = startIsAnchor ? range.end : range.start;
        let anchor: number;
        let focus: number;
        try {
          anchor = clamp(pointToPmPos(rootBlock, anchorPoint, doc), 0, maxPos);
          focus = clamp(pointToPmPos(rootBlock, focusPoint, doc), 0, maxPos);
        } catch {
          continue;
        }
        activePeerIds.add(processId);
        const caretElement = cursorMgr.getOrCreate(processId, focus, user, opts, receivedAt, renderCursor);
        const caretDecoration = Decoration.widget(focus, () => caretElement, {key: `presence-${processId}`, side: 10});
        decorations.push(caretDecoration);
        if (anchor !== focus) {
          const from = Math.min(anchor, focus);
          const to = Math.max(anchor, focus);
          const attrs = renderSelection(processId, user);
          const rangeDecoration = Decoration.inline(from, to, attrs, {inclusiveEnd: true, inclusiveStart: false});
          decorations.push(rangeDecoration);
        }
      }
    }
  }
  // Remove cached DOM elements for peers that are no longer active.
  cursorMgr.prune(activePeerIds);
  return DecorationSet.create(state.doc, decorations);
};
 
const clamp = (v: number, min: number, max: number): number => (v < min ? min : v > max ? max : v);
 
const isRgaSelection = (sel: unknown): sel is RgaSelection => {
  if (!Array.isArray(sel) || sel.length < 8) return false;
  const type = sel[5];
  return type === JsonCrdtDataType.str || type === JsonCrdtDataType.bin || type === JsonCrdtDataType.arr;
};
 
/**
 * Build an `RgaSelection` DTO from the current ProseMirror selection, for
 * broadcasting via the presence transport. Returns `null` when the view is
 * blurred or the Peritext ref is unavailable.
 */
const buildLocalPresenceDto = (view: EditorView, peritextRef: PeritextRef): RgaSelection | null => {
  if (!view.hasFocus()) return null;
  const api = peritextRef();
  if (!api) return null;
  const txt: Peritext = api.txt;
  const selection = view.state.selection;
  const p1 = pmPosToPoint(txt, selection.$anchor);
  const p2 = pmPosToPoint(txt, selection.$head);
  const range = txt.rangeFromPoints(p1, p2);
  const startIsAnchor = selection.anchor <= selection.head;
  const stableSelection: StablePeritextSelection = [range, startIsAnchor];
  return peritextPresence.toDto(txt, [stableSelection]);
};