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;
}