import React from "react"; import type { Block } from "../types"; import { InlineMarkdown } from "./InlineMarkdown"; import { ListItemBody } from "./ListItemBody"; import { CodeBlock } from "./blocks/CodeBlock"; import { HtmlBlock } from "./blocks/HtmlBlock"; import { Callout } from "./blocks/Callout"; import { AlertBlock } from "./blocks/AlertBlock"; import { BlockquoteBlock } from "./blocks/BlockquoteBlock"; import { TableBlock } from "./blocks/TableBlock"; import { MathBlock } from "./blocks/MathBlock"; import { QuestionBlock, type QuestionBlockProps } from "./blocks/QuestionBlock"; import { indexQuestionBlocks, isQuestionDirectiveKind, type IndexedQuestion, type QuestionAnswer, } from "@plannotator/core/question-block"; /** A question block rendered outside a Viewer's document index (plan diff, * other markdown surfaces): parsed alone, unnumbered. */ const standaloneQuestion = (block: Block): IndexedQuestion | undefined => { const [indexed] = indexQuestionBlocks([block]); return indexed ? { ...indexed, number: 0 } : undefined; }; export const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) => void; onOpenCodeFile?: (path: string) => void; imageBaseDir?: string; onImageClick?: (src: string, alt: string) => void; onToggleCheckbox?: (blockId: string, checked: boolean) => void; checkboxOverrides?: Map; orderedIndex?: number | null; githubRepo?: string; repoHost?: string; headingAnchorId?: string; onNavigateAnchor?: (hash: string) => void; /** `:::question` blocks: this block's entry in the document's question * index (numbering, key, prompt line) and the document's question count. * Absent, a question block is parsed on its own and shows no "N of M". */ question?: IndexedQuestion; questionTotal?: number; /** The answer to draw (from the annotation carrying `questionAnswer`). */ questionAnswer?: QuestionAnswer; /** Absent: question blocks render read-only. */ onAnswerQuestion?: (blockId: string, answer: QuestionAnswer | null, key: string) => void; /** Explicit save mode for question cards (see `QuestionBlock`). */ onSaveQuestionAnswer?: QuestionBlockProps['onSaveAnswer']; /** Label of the explicit-save-mode Save button (see `QuestionBlock`). */ saveQuestionAnswerLabel?: QuestionBlockProps['saveLabel']; /** Host actions in a question card's footer (see `QuestionBlock`). */ renderQuestionFooter?: QuestionBlockProps['renderFooter']; /** Whether answering this question records a decision (see `QuestionBlock`). */ questionDecisionRecording?: QuestionBlockProps['decisionRecording']; /** `'any'` lets a question without a decision line carry the tag (see `QuestionBlock`). */ questionDecisionScope?: QuestionBlockProps['decisionScope']; /** Makes the "Records a decision" tag a toggle (see `QuestionBlock`). */ onToggleQuestionDecisionRecording?: QuestionBlockProps['onToggleDecisionRecording']; /** Opens the host's decision card from the tag's words (see `QuestionBlock`). */ onOpenQuestionDecision?: QuestionBlockProps['onOpenDecision']; /** `'none'` hides the card's own status tag (see `QuestionBlock`). */ questionStatusTag?: QuestionBlockProps['statusTag']; /** The host's words for a question's status tag (see `QuestionBlock`). */ questionStatusLabel?: QuestionBlockProps['statusLabel']; /** Host content under a question's prompt (see `QuestionBlock`). */ renderQuestionBelowPrompt?: QuestionBlockProps['renderBelowPrompt']; }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter, questionDecisionRecording, questionDecisionScope, onToggleQuestionDecisionRecording, onOpenQuestionDecision, questionStatusTag, questionStatusLabel, renderQuestionBelowPrompt }) => { switch (block.type) { case 'heading': { const Tag = `h${block.level || 1}` as React.ElementType; const styles = { 1: 'text-2xl font-bold mb-4 mt-6 first:mt-0 tracking-tight', 2: 'text-xl font-semibold mb-3 mt-8 text-foreground/90', 3: 'text-base font-semibold mb-2 mt-6 text-foreground/80', }[block.level || 1] || 'text-base font-semibold mb-2 mt-4'; return ( ); } case 'blockquote': { if (block.alertKind) { return ( ); } // One quote whatever it holds: its content is the quote's own markdown // (paragraphs, code fences, lists, nested quotes), see BlockquoteBlock. return ( ); } case 'list-item': { const indent = (block.level || 0) * 1.25; // 1.25rem per level const isCheckbox = block.checked !== undefined; const isChecked = checkboxOverrides?.has(block.id) ? checkboxOverrides.get(block.id)! : block.checked; const isInteractive = isCheckbox && !!onToggleCheckbox; const textClass = `text-sm leading-relaxed ${isCheckbox && isChecked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`; const inlineProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, githubRepo, repoHost, onNavigateAnchor }; return (
onToggleCheckbox!(block.id, !isChecked) : undefined} textClassName={textClass} content={block.content} renderInline={(text) => } />
); } case 'code': return ; case 'table': return ( ); case 'math': return ; case 'hr': return
; case 'html': return ; case 'directive': { const kind = block.directiveKind || 'note'; if (isQuestionDirectiveKind(kind)) { // An unparseable question (no prompt) falls through to the callout. const indexed = question ?? standaloneQuestion(block); if (indexed) { return ( ); } } return ( ); } default: return (

); } };