All files / collaborative-prosemirror/src/sync toPmNode.ts

97.67% Statements 84/86
87.09% Branches 27/31
100% Functions 10/10
100% Lines 72/72

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    11x 11x                   554x   554x             4414x 4414x 4414x 4414x   4414x 4414x         4343x               900x 900x       11x 10515x 10515x 7139x             11x 554x   554x               833x 833x 833x 833x 833x 833x 4414x 4414x 4414x 4414x 153x 153x   4261x 4261x 4261x   833x 833x 833x       10515x 10515x 10515x 10515x 10515x 10515x 3523x 3523x 3523x 6254x 3523x       6992x 6992x 6992x 14666x 14666x 10121x 10121x   6992x       10121x 10121x 10121x 10121x 5531x 5531x 10489x 10489x 10489x 10489x 10489x 10488x   10488x 10488x   5531x      
/** Direct Peritext Fragment to ProseMirror Node converter with caching. */
 
import * as pmm from 'prosemirror-model';
import {type Block, LeafBlock, type Inline, Slice} from 'json-joy/lib/json-crdt-extensions';
import type {Fragment} from 'json-joy/lib/json-crdt-extensions/peritext/block/Fragment';
 
/**
 * Double-buffered cache for ProseMirror nodes, keyed by Peritext Block hash.
 * Designed to maximize reuse of unchanged nodes across renders while allowing
 * GC of stale entries without needing to track usage counts or timestamps.
 */
class PmNodeCache {
  /** Entries written/read during the *current* render pass. */
  private curr: Map<number, pmm.Node> = new Map();
  /** Entries from the *previous* render pass (read-only during current). */
  private prev: Map<number, pmm.Node> = new Map();
 
  /**
   * Look up a cached ProseMirror node by Peritext Block hash.
   * Automatically promotes a hit from `prev` into `curr`.
   */
  get(hash: number): pmm.Node | undefined {
    const curr = this.curr;
    let node = curr.get(hash);
    Iif (node !== undefined) return node;
    node = this.prev.get(hash);
    // Promote to current generation so it survives the next GC.
    if (node !== undefined) curr.set(hash, node);
    return node;
  }
 
  /** Store a freshly-built node. */
  set(hash: number, node: pmm.Node): void {
    this.curr.set(hash, node);
  }
 
  /**
   * Call once at the **end** of each render pass.
   * Drops entries that were not accessed during this render.
   */
  gc(): void {
    this.prev = this.curr;
    this.curr = new Map();
  }
}
 
const blockAttrs = (block: Block | LeafBlock): pmm.Attrs | null => {
  const data = block.attr();
  if (data && typeof data === 'object') for (const _ in data) return data as pmm.Attrs;
  return null;
};
 
/**
 * Stateful converter that turns a Peritext `Fragment` into a ProseMirror `doc`
 * Node, reusing unchanged sub-trees via {@link PmNodeCache}.
 */
export class ToPmNode {
  public readonly cache = new PmNodeCache();
 
  constructor(public readonly schema: pmm.Schema) {}
 
  /**
   * Convert a Peritext `Fragment` into a full ProseMirror document node.
   * Assumes `fragment.refresh()` has already been called so that all
   * `Block.hash` values are up-to-date.
   */
  convert(fragment: Fragment<string>): pmm.Node {
    const root = fragment.root;
    const children = root.children;
    const length = children.length;
    const pmChildren: pmm.Node[] = [];
    const cache = this.cache;
    for (let i = 0; i < length; i++) {
      const block = children[i];
      const hash = block.hash;
      const cached = cache.get(hash);
      if (cached) {
        pmChildren.push(cached);
        continue;
      }
      const pmNode = this.convBlock(block);
      cache.set(hash, pmNode);
      pmChildren.push(pmNode);
    }
    cache.gc();
    const docType = this.schema.nodes.doc;
    return docType.create(null, pmChildren);
  }
 
  private convBlock(block: Block | LeafBlock): pmm.Node {
    const schema = this.schema;
    const tag = block.tag();
    const typeName = tag ? tag + '' : 'paragraph';
    const nodeType = schema.nodes[typeName] ?? schema.nodes.paragraph;
    const attrs = blockAttrs(block);
    if (block instanceof LeafBlock) return nodeType.create(attrs, this.convInlines(block));
    const children = block.children;
    const length = children.length;
    const pmChildren: pmm.Node[] = new Array(length);
    for (let i = 0; i < length; i++) pmChildren[i] = this.convBlock(children[i]);
    return nodeType.create(attrs, pmChildren);
  }
 
  private convInlines(leaf: LeafBlock): pmm.Node[] {
    const schema = this.schema;
    const result: pmm.Node[] = [];
    for (let iterator = leaf.texts0(), inline: Inline<any> | undefined; (inline = iterator()); ) {
      const text = inline.text();
      if (!text) continue;
      const marks = this.convMarks(inline);
      result.push(schema.text(text, marks.length ? marks : undefined));
    }
    return result;
  }
 
  private convMarks(inline: Inline): readonly pmm.Mark[] {
    const schema = this.schema;
    const layers = inline.p1.layers;
    const length = layers.length;
    if (!length) return pmm.Mark.none;
    const marks: pmm.Mark[] = [];
    for (let i = 0; i < length; i++) {
      const slice = layers[i];
      Iif (!(slice instanceof Slice)) continue;
      const tag = slice.type() + '';
      const markType = schema.marks[tag];
      if (!markType) continue;
      const data = slice.data();
      const attrs: pmm.Attrs | null =
        data && typeof data === 'object' && !Array.isArray(data) ? (data as pmm.Attrs) : null;
      marks.push(markType.create(attrs));
    }
    return marks;
  }
}