All files / collaborative-presence/src str.ts

90.14% Statements 64/71
82.85% Branches 29/35
100% Functions 3/3
92.06% Lines 58/63

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 1303x 3x                                             3x 138x 138x 138x 138x 138x 160x 160x 160x 103x 160x   160x 160x 160x 160x 95x 95x 95x   65x         160x   138x 138x                           3x 211x 211x 174x 174x 174x 174x 174x         174x 174x 174x 63x 63x 27x       27x       27x         174x                       3x 105x 105x 105x 104x 104x 104x 104x 122x 122x 122x 122x 89x 89x 89x   33x     104x    
import {NodeType} from './constants';
import * as id from './id';
import type {ITimestampStruct, StrApi, StrNode, Model} from 'json-joy/lib/json-crdt';
import type {PresenceIdShorthand, PresencePoint, RgaSelection, PresenceCursor} from './types';
 
export type StrSelectionStrict = [anchor: number, focus?: number];
 
export type StrSelection =
  /** A single *caret* selection. */
  | number
  /** A *range* selection, with an anchor and focus. */
  | StrSelectionStrict;
 
/**
 * Converts offset-based string selections to CRDT ID-based selections. The
 * offset-based selections are relative to the current state of the string, and
 * will be converted to stable CRDT ID-based selections, which are stable across
 * concurrent edits.
 *
 * @param str The "str" node instance.
 * @param selections Collection of offset-based selections in the string.
 * @returns RGA selection entry, where all offset-based selection converted to
 *     stable CRDT ID-based selections.
 */
export const toDto = (str: StrApi, selections: StrSelection[]): RgaSelection => {
  const clock = str.api.model.clock;
  const sid = clock.sid;
  const nodeId: PresenceIdShorthand = id.toDto(sid, str.node.id);
  const cursors: PresenceCursor[] = [];
  for (const selection of selections) {
    let anchor: number = 0,
      focus: number = -1;
    if (typeof selection === 'number') anchor = selection;
    else [anchor, focus = anchor] = selection;
    if (focus === anchor) focus = -1;
    let cursor: PresenceCursor;
    try {
      const anchorId: ITimestampStruct = str.findId(anchor - 1);
      const anchorPoint: PresencePoint = [id.toDto(sid, anchorId)];
      if (focus >= 0) {
        const focusId: ITimestampStruct = str.findId(focus - 1);
        const focusPoint: PresencePoint = [id.toDto(sid, focusId)];
        cursor = [anchorPoint, focusPoint];
      } else {
        cursor = [anchorPoint];
      }
    } catch {
      cursor = [[id.toDto(sid, str.node.id)]];
    }
    cursors.push(cursor);
  }
  const selection: RgaSelection = ['', '', sid, clock.time, {}, NodeType.str, nodeId, cursors];
  return selection;
};
 
/**
 * Convert a CRDT ID to a view offset (the cursor position after that
 * character). Returns 0 when the ID matches the node itself (i.e. before
 * the first character).
 *
 * When `senderSid` and `presenceTime` are provided, the offset is advanced
 * past any characters the same peer inserted *after* the presence was sent.
 * This compensates for the common case where a remote patch arrives before
 * the updated presence message: without advancement the cursor would render
 * to the *left* of the newly inserted text instead of to its right.
 */
const findOffset = (str: StrNode, tsId: ITimestampStruct, senderSid?: number, presenceTime?: number): number => {
  const nodeId = str.id;
  if (nodeId.sid === tsId.sid && nodeId.time === tsId.time) return 0;
  const chunk = str.findById(tsId);
  Iif (!chunk) return 0;
  const pos = str.pos(chunk);
  const charIndex = pos + (chunk.del ? 0 : tsId.time - chunk.id.time);
  let offset = charIndex + 1;
  // Advance past characters the same peer inserted after this presence was
  // sent. Only consider chunks immediately following the cursor's chunk — if
  // the cursor character is in the middle of its chunk there can be no new
  // inserts at the cursor position (the chunk would have been split).
  Eif (senderSid !== undefined && presenceTime !== undefined) {
    const atEndOfChunk = tsId.time - chunk.id.time === chunk.span - 1;
    if (atEndOfChunk) {
      let c = str.next(chunk);
      while (c) {
        Iif (c.del) {
          c = str.next(c);
          continue;
        }
        Iif (c.id.sid === senderSid && c.id.time > presenceTime) {
          offset += c.span;
          c = str.next(c);
        } else {
          break;
        }
      }
    }
  }
  return offset;
};
 
/**
 * Converts a CRDT ID-based RGA selection back to offset-based string
 * selections. This is the inverse of {@link toDto}.
 *
 * @param model The JSON CRDT model containing the "str" node.
 * @param selection The RGA selection DTO to convert.
 * @returns Collection of offset-based selections, or an empty array if the
 *     node is not found or is not a "str" node.
 */
export const fromDto = (model: Model<any>, selection: RgaSelection): StrSelectionStrict[] => {
  const [_documentId, _uiLocationId, sid, time, _meta, type, nodeIdDto, cursors] = selection;
  const result: StrSelectionStrict[] = [];
  if (type !== NodeType.str) return result;
  const nodeId = id.fromDto(sid, nodeIdDto);
  const str = model.index.get(nodeId) as StrNode | undefined;
  Iif (!str || str.name() !== 'str') return result;
  for (const cursor of cursors) {
    const [anchorPointDto, focusPointDto] = cursor;
    const anchorId = id.fromDto(sid, anchorPointDto[0]);
    const anchorOffset = findOffset(str, anchorId, sid, time);
    if (focusPointDto) {
      const focusId = id.fromDto(sid, focusPointDto[0]);
      const focusOffset = findOffset(str, focusId, sid, time);
      result.push([anchorOffset, focusOffset]);
    } else {
      result.push([anchorOffset]);
    }
  }
  return result;
};