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

20.63% Statements 13/63
0% Branches 0/28
0% Functions 0/8
20.37% Lines 11/54

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 1879x 9x                 9x       9x         9x               9x               9x                                   9x                                       9x                                                                                               9x                                                                                                                     9x            
import {rule, keyframes} from 'nano-theme';
import {Colors} from '@jsonjoy.com/ui/lib/styles/color/Colors';
import type {DecorationAttrs} from 'prosemirror-view';
import type {CursorRenderer, PresencePluginOpts, SelectionRenderer} from './plugin';
 
export interface PresenceUser {
  name?: string;
  color?: string;
}
 
const generateColor = (str: string, alpha?: number): string => new Colors().hash(str).setA(100 * (alpha ?? 1)) + '';
 
// --------------------------------------------------------------------- Cursor
 
const blinkAnimation = keyframes({
  '0%, 100%': {borderLeftColor: 'var(--peritext-cursor-color)'},
  '50%': {borderLeftColor: 'transparent'},
});
 
const labelFadeAnimation = keyframes({
  from: {opacity: 1, transform: 'translateX(-50%) translateY(0)'},
  to: {opacity: 0, transform: 'translateX(-50%) translateY(4px)'},
});
 
/** Cursor lifecycle: dim at 50% of the duration, disappear at 100%.
 * The total duration is set via `animation-duration` in JS so the dim/hide
 * thresholds map to wall-clock seconds configured by the plugin. */
const lifecycleAnimation = keyframes({
  '0%': {opacity: 1},
  '49.9%': {opacity: 1},
  '50%': {opacity: 0.3},
  '99.9%': {opacity: 0.3},
  '100%': {opacity: 0},
});
 
const cursorLabelClass = rule({
  pos: 'absolute',
  bottom: 'calc(100% + 1px)',
  left: '50%',
  transform: 'translateX(-50%) translateY(0)',
  whiteSpace: 'nowrap',
  fz: '11px',
  fontWeight: 600,
  lineHeight: 1.2,
  pd: '1px 4px',
  bdrad: '3px',
  col: '#fff',
  pe: 'none',
  us: 'none',
  op: 1,
  z: 10,
}).trim();
 
const cursorClass = rule({
  pos: 'relative',
  bdl: '2px solid',
  ml: '-1px',
  mr: '-1px',
  pe: 'none',
  wordBreak: 'normal',
  [`&:hover .${cursorLabelClass}`]: {
    op: 1,
    transform: 'translateX(-50%) translateY(0)',
    z: 20,
  },
}).trim();
 
/**
 * Default cursor renderer — a colored caret line with a floating name label.
 * All lifecycle stages (blink, label fade, dim, disappear) are driven by CSS
 * animations so no JS timers are needed. The element is designed to be cached
 * and reused by {@link CursorManager} across decoration rebuilds.
 */
export const renderCursor: CursorRenderer<any> = (
  peerId: string,
  user?: PresenceUser,
  opts?: PresencePluginOpts<any>,
): HTMLElement => {
  const fadeAfterMs = opts?.fadeAfterMs ?? 3_000;
  const dimAfterMs = opts?.dimAfterMs ?? 30_000;
  const hideAfterMs = opts?.hideAfterMs ?? 60_000;
  const color = user?.color ?? generateColor(peerId);
  const name = user?.name ?? peerId.slice(0, 2);
  const blinkIterations = Math.ceil(dimAfterMs / 1000);
  const el = document.createElement('span');
  el.className = cursorClass;
  el.style.borderColor = color;
  el.style.setProperty('--peritext-cursor-color', color);
  el.style.animation = `${lifecycleAnimation} ${hideAfterMs}ms linear forwards, ${blinkAnimation} 1s ease-in-out ${blinkIterations} forwards`;
  el.style.animationDelay = `0ms, ${-(Date.now() % 1000)}ms`;
  const label = document.createElement('div');
  label.className = cursorLabelClass;
  label.style.backgroundColor = color;
  label.textContent = name;
  if (fadeAfterMs > 0) label.style.animation = `${labelFadeAnimation} 0.3s ease ${fadeAfterMs}ms forwards`;
  el.append('\u2060', label, '\u2060');
  return el;
};
 
// -------------------------------------------------------------- CursorManager
 
interface CursorEntry {
  /** The cached DOM element. */
  el: HTMLElement;
  /** Last known ProseMirror widget position. */
  pos: number;
  /** The label sub-element (for hover re-reveal). */
  label: HTMLElement;
}
 
/**
 * Manages cached cursor DOM elements keyed by remote peer id.
 *
 * By caching elements we ensure:
 *
 * 1. CSS animations survive ProseMirror decoration rebuilds (the same
 *    `HTMLElement` is returned from the `toDOM` factory).
 * 2. When a remote cursor moves, the lifecycle animation is restarted so the
 *    cursor re-appears at full opacity and the label is re-shown.
 * 3. No JS timers need to be managed — everything is CSS-driven.
 */
export class CursorManager<Meta extends object = object> {
  private cache = new Map<string, CursorEntry>();
 
  getOrCreate(
    processId: string,
    pos: number,
    user: PresenceUser | undefined,
    opts: PresencePluginOpts<Meta>,
    receivedAt: number,
    renderFn: CursorRenderer<Meta>,
  ): HTMLElement {
    const existing = this.cache.get(processId);
    if (existing) {
      if (existing.pos !== pos) {
        existing.pos = pos;
        this.resetLifecycle(existing);
      }
      return existing.el;
    }
    // First time we see this peer — create the element.
    const el = renderFn(processId, user, opts);
    // If the presence data is already aged, fast-forward the lifecycle
    // animation so the cursor starts at the correct visual phase.
    const age = Date.now() - receivedAt;
    if (age > 0) {
      // A negative animation-delay makes the animation start mid-way.
      // The first value is for the lifecycle animation; the second for blink.
      el.style.animationDelay = `-${age}ms, ${-(Date.now() % 1000)}ms`;
    }
    const label = el.querySelector(`.${cursorLabelClass}`) as HTMLElement;
    this.cache.set(processId, {el, pos, label});
    return el;
  }
 
  prune(activeIds: Set<string>): void {
    for (const id of this.cache.keys()) if (!activeIds.has(id)) this.cache.delete(id);
  }
 
  destroy(): void {
    this.cache.clear();
  }
 
  /** Restart all CSS animations on a cached cursor so it re-appears at full
   * opacity with the label visible — called when the remote cursor moves. */
  private resetLifecycle(entry: CursorEntry): void {
    const {el, label} = entry;
    for (const anim of el.getAnimations()) anim.currentTime = 0;
    if (label) for (const anim of label.getAnimations()) anim.currentTime = 0;
 
    // Re-sync blink delay to global epoch.
    const parts = (el.style.animationDelay || '').split(',');
    parts[1] = ` ${-(Date.now() % 1000)}ms`;
    el.style.animationDelay = parts.join(',');
  }
}
 
// ------------------------------------------------------------------ Selection
 
/** Default selection renderer — a semi-transparent background color. */
export const renderSelection: SelectionRenderer = (peerId: string, user?: PresenceUser): DecorationAttrs => {
  const color = user?.color ?? generateColor(peerId, 0.3);
  return {
    style: `background-color: ${color}`,
  };
};