RecordingFenceNode.tsx3.4 KBView on GitHub 'use client';
import type { MarkdownParseHelpers, MarkdownToken } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { Node } from '@tiptap/core';
import { MeetingRecordingPlayer } from '@/modules/documents/recording/MeetingRecordingPlayer';
import { clockToSeconds } from '@/modules/documents/recording/timestamps';
import { decodeFenceAttr, safeHref } from '@/modules/documents/coaching/fence-attrs';
import { parseRecording } from '@/modules/documents/recording/recording-fence';
/**
* Renders a ```recording fenced block as the meeting itself, playable in place.
*
* This is the whole-call embed; the per-moment one lives on the moment card, which reuses the
* same player. The body is kept verbatim on the node so it round-trips losslessly to markdown,
* and a body that fails to parse renders raw rather than a broken frame.
*/
function RecordingView({ node }: NodeViewProps) {
const body = (node.attrs.body as string) ?? '';
const recording = parseRecording(body);
if (!recording) {
return (
<NodeViewWrapper as="div" className="my-3" contentEditable={false}>
<pre className="rounded-md border border-border bg-muted p-3 font-mono text-xs">{body}</pre>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper
as="div"
className="my-4 overflow-hidden rounded-lg border border-border bg-background"
contentEditable={false}
>
{recording.title ? (
<div className="border-b border-border px-3 py-2 text-sm font-semibold text-foreground">
{recording.title}
</div>
) : null}
<div className="p-3">
<MeetingRecordingPlayer
externalId={recording.externalId}
atSeconds={clockToSeconds(recording.at)}
// The body is agent-authored document content, so the link is untrusted input.
fallbackUrl={safeHref(recording.recording) ?? null}
/>
</div>
</NodeViewWrapper>
);
}
const FENCE_RE = /^```recording[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*(?:\r?\n|$)/;
export const RecordingFenceNode = Node.create({
name: 'recordingBlock',
group: 'block',
atom: true,
selectable: true,
addAttributes() {
return { body: { default: '' } };
},
parseHTML() {
return [
{
tag: 'div[data-recording-block]',
getAttrs: (el) => ({
body: decodeFenceAttr((el as HTMLElement).getAttribute('data-recording-block')),
}),
},
];
},
renderHTML({ node }) {
return [
'div',
{ 'data-recording-block': encodeURIComponent((node.attrs.body as string) ?? '') },
];
},
addNodeView() {
return ReactNodeViewRenderer(RecordingView);
},
markdownTokenizer: {
name: 'recordingBlock',
level: 'block',
start(src: string) {
const idx = src.indexOf('```recording');
return idx >= 0 ? idx : -1;
},
tokenize(src: string): MarkdownToken | undefined {
const match = FENCE_RE.exec(src);
if (!match) return undefined;
return { type: 'recordingBlock', raw: match[0], body: match[1] };
},
},
parseMarkdown(token=[redacted], helpers: MarkdownParseHelpers) {
return helpers.createNode('recordingBlock', { body: token.body ?? '' }, []);
},
renderMarkdown(node: { attrs?: Record<string, unknown> }) {
return '```recording\n' + ((node.attrs?.body as string) ?? '') + '\n```';
},
});