BlockCalloutNode.tsx2.1 KBView on GitHub 'use client';
import { Node, mergeAttributes } from '@tiptap/core';
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
/**
* Block-level callout that can hold block content (paragraphs, task lists, etc.)
* — unlike the inline-only `callout` node. Used for the per-stage "Exit criteria"
* box, which wraps a checkbox task list. `emoji` + `label` render a small header
* above the editable body.
*/
function BlockCalloutView({ node }: NodeViewProps) {
const emoji = (node.attrs.emoji as string) || '🏁';
const label = (node.attrs.label as string) || '';
return (
<NodeViewWrapper
as="div"
data-block-callout=""
className="border-border bg-muted/40 my-2 rounded-xl border px-4 py-3"
>
{label && (
<div
className="text-muted-foreground mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide"
contentEditable={false}
suppressContentEditableWarning
>
<span className="select-none">{emoji}</span>
{label}
</div>
)}
<NodeViewContent className="text-sm" />
</NodeViewWrapper>
);
}
export const BlockCalloutNode = Node.create({
name: 'blockCallout',
group: 'block',
content: 'block+',
defining: true,
addAttributes() {
return {
emoji: {
default: '🏁',
parseHTML: (element) => element.getAttribute('data-emoji'),
renderHTML: (attributes) => (attributes.emoji ? { 'data-emoji': attributes.emoji } : {}),
},
label: {
default: '',
parseHTML: (element) => element.getAttribute('data-label'),
renderHTML: (attributes) => (attributes.label ? { 'data-label': attributes.label } : {}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-block-callout]' }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes({ 'data-block-callout': '' }, HTMLAttributes), 0];
},
addNodeView() {
return ReactNodeViewRenderer(BlockCalloutView);
},
});