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