cell-content.ts3.3 KBView on GitHub
/**
 * The cell value ⇄ ProseMirror bridge for `CellEditor`.
 *
 * A cell is a STRING with `[[type: id]]` tokens in it; the editor is a ProseMirror
 * document with inline atom nodes. These two functions are the only translation, and they
 * are exact inverses over the three node types the cell editor can produce — which is what
 * makes "type `@`, blur, focus again" a fixed point rather than a lossy round trip.
 *
 * `editor.getMarkdown()` is deliberately NOT used: `ConversationNode.renderMarkdown` emits
 * the agenda's `@[id]` form, not the `[[conversation: id]]` token a cell stores. Walking
 * the doc ourselves keeps the cell grammar in one place.
 *
 * Reference kinds with no editor node (task, draft, person, company) stay literal text in
 * the editor, so they round-trip byte-for-byte and remain hand-editable.
 */

import type { JSONContent } from '@tiptap/core';

import { formatCellRef, splitCellValue } from './cell-refs';

/** Node type inserted by each `@`/`{{`/`[[` suggestion, and the token it maps to. */
const REF_TYPE_BY_NODE: Record<string, { refType: string; idAttr: string }> = {
  conversationNode: { refType: 'conversation', idAttr: 'conversationId' },
  eventNode: { refType: 'event', idAttr: 'eventId' },
  fileLink: { refType: 'doc', idAttr: 'documentId' },
};

const NODE_BY_REF_TYPE: Record<string, { nodeType: string; idAttr: string }> = {
  conversation: { nodeType: 'conversationNode', idAttr: 'conversationId' },
  event: { nodeType: 'eventNode', idAttr: 'eventId' },
  doc: { nodeType: 'fileLink', idAttr: 'documentId' },
};

/** A cell's stored string → the single-paragraph document the editor opens with. */
export function cellValueToContent(value: string): JSONContent {
  const inline: JSONContent[] = [];
  for (const segment of splitCellValue(value)) {
    if (segment.kind === 'text') {
      inline.push({ type: 'text', text: segment.text });
      continue;
    }
    const node = NODE_BY_REF_TYPE[segment.ref.refType];
    if (!node) {
      // No editor node for this ref kind — keep the token as the literal text it is.
      inline.push({ type: 'text', text: formatCellRef(segment.ref.refType, segment.ref.refId) });
      continue;
    }
    inline.push({ type: node.nodeType, attrs: { [node.idAttr]: segment.ref.refId } });
  }
  return {
    type: 'doc',
    content: [inline.length > 0 ? { type: 'paragraph', content: inline } : { type: 'paragraph' }],
  };
}

/** The editor's document → the string committed with one `Y.Map.set`. */
export function contentToCellValue(doc: JSONContent | null | undefined): string {
  if (!doc) return '';
  // A single-line editor has exactly one paragraph, but a paste can briefly produce more;
  // joining with a space keeps the commit single-line, which the pipe mirror requires.
  const paragraphs = (doc.content ?? []).map((block) => serializeInline(block.content ?? []));
  return paragraphs
    .filter((text, index) => text !== '' || index === 0)
    .join(' ')
    .trim();
}

function serializeInline(nodes: JSONContent[]): string {
  let out = '';
  for (const node of nodes) {
    if (node.type === 'text') {
      out += node.text ?? '';
      continue;
    }
    const ref = node.type ? REF_TYPE_BY_NODE[node.type] : undefined;
    if (!ref) continue;
    const id = String((node.attrs ?? {})[ref.idAttr] ?? '');
    if (id) out += formatCellRef(ref.refType, id);
  }
  return out;
}