import { generateId } from '../utils/generateId'; import React, { useRef, useState, useEffect, useLayoutEffect, useMemo, forwardRef, useImperativeHandle, useCallback, lazy, Suspense } from 'react'; import { createPortal } from 'react-dom'; import { collectDocumentImages, ImageLightbox, type LightboxImage } from './ImageLightbox'; import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode, type HostDraft } from '../types'; import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight'; import { paintCodeBlockMark } from '../utils/codeBlockMark'; import { useFenceTheme } from '../hooks/useFenceTheme'; import { computeListIndices, groupBlocks, selectionSourceLines, type Frontmatter, type FrontmatterValue } from '../utils/parser'; import { buildHeadingSlugMap } from '../utils/slugify'; import { copyTextToClipboard } from '../utils/clipboard'; import { BlockRenderer } from './BlockRenderer'; import { indexQuestionBlocks, type IndexedQuestion, type ParsedQuestion, type QuestionAnswer } from '@plannotator/core/question-block'; import type { QuestionBlockProps } from './blocks/QuestionBlock'; import { resolveQuestionAnswers } from '../utils/questionAnswers'; import { CodeBlock } from './blocks/CodeBlock'; import { TableBlock } from './blocks/TableBlock'; import { TableToolbar } from './blocks/TableToolbar'; import { TablePopout } from './blocks/TablePopout'; import { CodePathValidationContext } from './CodePathValidationContext'; import { LocalLinkContext, type LocalLinkResolver } from './LocalLinkContext'; import { useValidatedCodePaths } from '../hooks/useValidatedCodePaths'; import { AnnotationToolbar } from './AnnotationToolbar'; import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker'; /** * The diagram engine — the renderer slot, the canvas, the comment overlay, * the popout, and (through the viewer) CodeMirror — is loaded by the first * diagram fence in the document and by nothing else. A markdown document * with no diagram never reaches for it; a chunked host that statically * imports this Viewer pays none of it on a plain document read. * * The Suspense fallback is the SAME pending state the block itself shows * while its engine loads (`DiagramPending`), inside the same boxes, so the * source fence paints once and the two waits read as one. */ const MermaidBlock = lazy(async () => ({ default: (await import('./MermaidBlock')).MermaidBlock })); const GraphvizBlock = lazy(async () => ({ default: (await import('./GraphvizBlock')).GraphvizBlock })); // Debug error boundary to catch silent toolbar crashes class ToolbarErrorBoundary extends React.Component< { children: React.ReactNode }, { error: Error | null } > { state = { error: null as Error | null }; static getDerivedStateFromError(error: Error) { return { error }; } componentDidCatch(error: Error) { console.error('AnnotationToolbar crashed:', error); } render() { if (this.state.error) { return
Toolbar error: {this.state.error.message}
; } return this.props.children; } } import { CommentPopover, type CommentAskAIHandler } from './CommentPopover'; import { TaterSpriteSitting } from './TaterSpriteSitting'; import { AttachmentsButton } from './AttachmentsButton'; import { MessagesIcon } from './icons/MessagesIcon'; import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims'; import { DiagramBlockPending } from './diagram/DiagramPending'; import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages'; import { getIdentity } from '../utils/identity'; import { type QuickLabel } from '../utils/quickLabels'; import type { SelectionAction } from '../utils/selectionActions'; import type { MentionSource } from '../utils/mentions'; import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges'; import { PinpointOverlay } from './PinpointOverlay'; import { usePinpoint } from '../hooks/usePinpoint'; import { useAnnotationHighlighter, type AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter'; import { useVimSelection } from '../hooks/useVimSelection'; import { getScrollViewportIntersectionRoot, getScrollViewportRect, getScrollViewportTop, scrollViewportTo, useScrollViewport, } from '../hooks/useScrollViewport'; import { decodeAnchorHash } from '../utils/anchors'; import { VimModeOverlay } from './VimModeOverlay'; import { AnnotationToolstrip } from './AnnotationToolstrip'; import { resolveCompactHeaderGeometry, snapCompactHeaderWidth, } from './compactHeaderLayout'; /** Typed controls required for Viewer's opt-in, in-flow annotation header. */ export interface ViewerAnnotationHeaderConfig { /** Persist and apply a Select or Pinpoint input-method change. */ readonly onInputMethodChange: (method: InputMethod) => void; /** Persist and apply an annotation-mode change. */ readonly onModeChange: (mode: EditorMode) => void; /** Omit Quick Label without changing or coercing the current mode. */ readonly hideQuickLabel?: boolean; } /** Public properties for the Markdown document Viewer. */ export interface ViewerProps { /** * Opt-in host capability, passed straight through to the selection * toolbars: the host's own commands for the current selection, rendered as * one wand button that opens the package's dropdown. Absent → unchanged. */ selectionActions?: SelectionAction[]; /** Opt-in host capability: the glyph on the `selectionActions` button on * both toolbars. Absent → the package's own wand. */ selectionActionsIcon?: React.ReactNode; /** * Whether the package's quick labels are offered on the selection toolbars * (default true). `false` hides the Zap picker and the Alt+digit label * shortcuts; the 👍 button is unaffected. */ quickLabels?: boolean; /** * Opt-in host capability, forwarded to BOTH comment composers this viewer * mounts (the text-selection composer and the global / code-block one): the * `@` mention source for the composer's picker. The picked ids ride onto the * created annotation as `Annotation.mentions`. Absent → unchanged. */ mentionSource?: MentionSource; blocks: Block[]; markdown: string; frontmatter?: Frontmatter | null; annotations: Annotation[]; onAddAnnotation: (ann: Annotation) => void; onSelectAnnotation: (id: string | null) => void; selectedAnnotationId: string | null; mode: EditorMode; inputMethod?: InputMethod; taterMode: boolean; globalAttachments?: ImageAttachment[]; onAddGlobalAttachment?: (image: ImageAttachment) => void; onRemoveGlobalAttachment?: (path: string) => void; repoInfo?: { display: string; branch?: string; host?: string } | null; /** * The repository bare `#123` / `@user` refs link into, WITHOUT the repo * badge `repoInfo` also draws (a host that renders many small documents, * such as the Inbox's messages). Wins over `repoInfo` for links only; * `null` and absent both fall back to `repoInfo`, as before. */ refRepo?: { display: string; host?: string } | null; stickyActions?: boolean; /** * Render compact annotation controls and the existing document actions in * one measured, in-flow header owned by Viewer. The header reserves its * responsive height before document content and follows `stickyActions`. * Omit this prop to retain the legacy floating action bar exactly. Read-only * viewers suppress the annotation header rather than expose dead controls. */ annotationHeader?: ViewerAnnotationHeaderConfig; /** Render the plan as a floating card on a grid background (shadow/border/padding). Default false. */ gridEnabled?: boolean; onOpenLinkedDoc?: (path: string) => void; onOpenCodeFile?: (path: string) => void; /** * Opt-in host capability: decide what a link to a local path does * (`[the mock](mock.html)`, markdown and raw-HTML `` alike; never * `#anchors`, `//host` or a URL with a scheme). Return `{ kind: 'open' }` * to open it yourself, `{ kind: 'unavailable', title }` to draw it as * inert text, or null for the default. Absent → unchanged. */ resolveLocalLink?: LocalLinkResolver; imageBaseDir?: string; /** * A click on an image in the document. Given: the host handles it (the * Inbox opens a message's image in its own viewer) and no lightbox opens. * Absent: the lightbox, as always. */ onImageClick?: (src: string, alt: string) => void; /** * The lightbox steps through every image the document shows (← →, the * peek; approval "inbox-image-viewer": plan review and annotate's light * version). Absent or false: the single image overlay, as always. */ imageLightboxStepping?: boolean; /** Directory the active document lives in — used by the code-path validator * so out-of-tree relative references (e.g. `../foo.ts` in a linked doc) * resolve against the doc's own directory rather than only cwd. */ codePathBaseDir?: string; /** Opt out of `/api/doc/exists` code-path validation (host without that * endpoint). Default undefined for Plannotator => validation stays on. */ disableCodePathValidation?: boolean; linkedDocInfo?: LinkedDocBadgeInfo | null; // Plan diff props planDiffStats?: { additions: number; deletions: number; modifications: number } | null; isPlanDiffActive?: boolean; onPlanDiffToggle?: () => void; hasPreviousVersion?: boolean; /** Baseline suffix + tooltip for the plan-diff badge (see DocBadges) — * annotate/folder sessions pass "since last review"; plan review omits. */ planDiffBaselineLabel?: string; planDiffBaselineTooltip?: string; /** Show amber "Demo" badge (portal mode, no shared content loaded) */ showDemoBadge?: boolean; /** Max width in px for the plan card; null removes the cap entirely. */ maxWidth?: number | null; /** Label for the copy button (default: "Copy plan") */ copyLabel?: string; /** * Compactness of the action button labels. See ActionsLabelMode in * types.ts. Defaults to 'full' to preserve the original look for * callers that don't measure plan-area width. */ actionsLabelMode?: ActionsLabelMode; archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null; /** Source attribution for HTML/URL annotations (e.g. URL or filename) */ sourceInfo?: string; /** Absolute path of the annotated source file for the Open-in-app control. */ openInAppPath?: string | null; /** * Message picker affordance — annotate-last mode only. Shown as a button in * the sticky-top action bar so the user can switch to a different recent * assistant message. Clicking opens the full picker in the left sidebar's * Messages tab. */ messagePickerInfo?: { current: number; total: number; onOpen: () => void }; // Checkbox toggle props onToggleCheckbox?: (blockId: string, checked: boolean) => void; checkboxOverrides?: Map; /** Answer handler for `:::question` blocks. The Viewer draws each answer * from the `annotations` row carrying `questionAnswer` for that question's * key; a change calls this with the next answer, or null when it became * empty, and the host upserts or removes that annotation (see * `upsertQuestionAnswerAnnotation` in `utils/questionAnswers`). Absent (or * `readOnly`), question blocks render read-only. */ onAnswerQuestion?: (blockId: string, answer: QuestionAnswer | null, key: string) => void; /** Host-kept answers, keyed by the question's (de-duplicated) key from * `indexQuestionBlocks` / `findQuestionBlocks`. When given, the cards read * their answers from here and never from `annotations`; entries that fail * `parseQuestionAnswer` are ignored. Absent: answers come from the * annotations carrying `questionAnswer` (Plannotator's own path). */ questionAnswers?: ReadonlyMap | Readonly>; /** Explicit save mode for question cards: edits stay a draft in the card, * which shows Save answer and Cancel (and no Skip); Save calls this with * the question's key and the answer, or null when the draft is empty. Return * a promise to keep the draft until it settles (a rejection keeps it for a * retry). Takes precedence over `onAnswerQuestion`. Ignored when * `readOnly`. */ onSaveQuestionAnswer?: (key: string, answer: QuestionAnswer | null) => void | Promise; /** Label of the explicit-save-mode Save button, or a function of the * question returning it. Default "Save answer"; never derived from * `decisionOnAnswer` (only the host knows whether saving records one). */ saveQuestionAnswerLabel?: string | ((question: ParsedQuestion) => string); /** Host actions at the right of a question card's footer (e.g. "Mark as * decision"), given the indexed question and its saved answer. Rendered in * read-only cards too; return null for none. */ renderQuestionFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode; /** Whether answering a question records a decision, per question. On a * `Decision: when answered` question: false draws its "Records a * decision" tag dimmed with a dotted outline and hides the "Answering this * records a decision" row; true or undefined keeps today's card. On a * question with no decision line it is ignored unless * `questionDecisionScope` is `'any'`; then a boolean adds the tag in that * state (true also shows the row) and undefined adds nothing. Has no * effect on a question whose decision is already recorded. */ questionDecisionRecording?: (question: IndexedQuestion) => boolean | undefined; /** Which questions can carry the "Records a decision" tag (ui 0.52.1). * `'when-answered'` (default, the 0.52.0 behaviour): only questions with * `Decision: when answered`. `'any'`: also a question with no decision * line, when `questionDecisionRecording` returns a boolean for it. */ questionDecisionScope?: 'when-answered' | 'any'; /** Whether `:::question` cards read `Decision:` lines (default true). False * (a host that records no decisions, such as the Plannotator Inbox): a * `Decision:` line is shown as context and no question carries a decision * tag or row from it. The prompt, key and numbering are unchanged. */ questionDecisionLines?: boolean; /** Makes a question's "Records a decision" tag a toggle button * (`aria-pressed`); a click calls this with the question's key and the * next state, which the host stores and returns from * `questionDecisionRecording`. Ignored when `readOnly`. */ onToggleQuestionDecisionRecording?: (key: string, next: boolean) => void; /** Opens the host's decision card (ui 0.52.1): the tag splits into a * diamond switch (the toggle above) and the words "Records a decision" as * a button that calls this with the question's key and the words' element * (anchor your popover to it). Offered on and off. Ignored when * `readOnly`. */ onOpenQuestionDecision?: (key: string, anchor: HTMLElement) => void; /** Per question, true draws its decision tag as a plain tag: no switch and * no opener, as under `readOnly`, while the other questions keep theirs * (a host whose message holds one sent and one open question). Absent: * every question follows `readOnly`. */ questionDecisionLocked?: (question: IndexedQuestion) => boolean; /** Per question, true draws that card read-only, as under `readOnly` (no * picks, no Other, no note, no Skip, no Accept recommended, and its * decision tag a plain tag), while the other questions stay answerable (a * host whose message holds one sent and one open question). Absent: every * question follows `readOnly`. */ questionReadOnly?: (question: IndexedQuestion) => boolean; /** `'none'` hides each question card's own status tag (Open / Answered / * Settled / Skipped) so the host can draw its own; the decision tags stay. * Default `'card'`. */ questionStatusTag?: 'card' | 'none'; /** The host's own words for a question's status tag (ui seam, see * `QuestionBlock` `statusLabel`): the Plannotator Inbox says "Not sent" for * a saved, unsent pick. Absent: the card's own tags. */ questionStatusLabel?: QuestionBlockProps['statusLabel']; /** A host's own card in place of a question's (ui seam for a question the * host no longer lets anyone answer, such as the Plannotator Inbox's * replaced questions). Called per question; a returned node is drawn * instead of the answer card, inside a wrapper carrying the block's * `data-block-id`; `undefined` keeps the answer card. Absent: every * question draws its answer card (today's behaviour). */ renderQuestionCard?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode | undefined; /** Host content right under a question card's prompt, per question (e.g. * "Replaces "); null for none. Absent: nothing there. */ renderQuestionBelowPrompt?: (question: IndexedQuestion) => React.ReactNode; onAskAI?: CommentAskAIHandler; /** Whether comment popovers offer image attachments. Hosts without an * uploadTransport pass false so the attach affordance never dead-ends. * Default true — today's behavior. */ allowImages?: boolean; /** View-only mode: suppresses every annotation-creation entry point * (selection toolbar, comment popovers, quick labels, pinpoint, global * comment, attachments, checkbox toggles). Existing annotations still * render and remain selectable. Default false — today's behavior. */ readOnly?: boolean; /** * Opt-in host capability: the document is read, not annotated. Every * annotation affordance is off exactly as under `readOnly` (selection and * code-block toolbars, comment composers, pinpoint, the annotation header, * global comment and attachments, Vim), the document actions (Global * comment, Copy) are not drawn, but question cards and checkboxes keep their * handlers. For a host that shows a message with answerable `:::question` * blocks and no comments on its text. Absent → unchanged. */ answerOnly?: boolean; /** * Opt-in host capability, with `answerOnly`: the reader may comment on the * text. A text selection opens the selection toolbar, comment-only * (Comment and the one-click 👍; no Delete, no label picker), and its * comment composer; `annotations` are drawn as highlights. Question cards * are left out of selection (their text is not commented on), and nothing * else of authoring comes back (no pinpoint, code-block toolbar, Global * comment or Copy, no Vim). For a host that shows a message whose words * can be commented on (the Plannotator Inbox's agent messages). Ignored * without `answerOnly` and under `readOnly`. Absent → unchanged. */ textComments?: boolean; /** Opt-in Vim-style keyboard selection. Default false for compatibility. */ vimModeEnabled?: boolean; /** Replace the compact Vim badge with the live video-style key HUD. */ vimHudEnabled?: boolean; /** Show the bottom-right key panel without affecting the HUD reticle. */ vimHudKeyPanelEnabled?: boolean; /** Persist a user request to hide the bottom-right key panel. */ onVimHudKeyPanelChange?: (enabled: boolean) => void; /** Fires once per highlight-restore pass with what it tried and what it could * not anchor, so a host can mark the leftovers in its annotation panel. */ onRestoreReport?: (report: AnnotationRestoreReport) => void; /** * Opt-in host capability: the composer handed to the host (see * `HostDraft`). A selection settling, a pinpoint tap or Comment mode reports * a draft here instead of opening the toolbar or a composer (a pinpoint tap * reports `intent: 'compose'`), and null when it closes. The document * actions (Global comment, Copy) and the code-block and table hover * toolbars are not drawn, and a pinpoint tap on a code block does nothing. * The host saves the comment and draws it through `annotations` / * `applySharedAnnotations`. Ignored when authoring is off. Absent → * unchanged. */ onHostDraft?: (draft: HostDraft | null) => void; } export interface ViewerHandle { removeHighlight: (id: string) => void; clearAllHighlights: () => void; applySharedAnnotations: (annotations: Annotation[]) => void; /** `HtmlViewer` only: move a pinned draft to the element around it * (`'parent'`) or back in (`'child'`), for a host that composes * (`onHostDraft`). The markdown `Viewer` does not implement it. */ stepPin?: (direction: 'parent' | 'child') => void; /** `HtmlViewer` only: scroll the pinned draft into the middle of the * frame, for a host whose panel covers part of it. */ revealPin?: () => void; /** Markdown `Viewer` only, for a host that composes (`onHostDraft`) on a * touch screen: make the page's text selection a draft now (the host's own * Comment action), reported through `onHostDraft` as `'selection'`. * Returns false when nothing in the document is selected. */ takeSelection?: () => boolean; } interface CodeBlockToolbarTarget { readonly block: Block; readonly element: HTMLElement; readonly activation: 'pointer' | 'keyboard'; } // Named type guard so both taken and fallthrough branches narrow. function isFrontmatterMap(value: FrontmatterValue): value is { [key: string]: FrontmatterValue } { return typeof value === 'object' && value !== null && !Array.isArray(value); } /** * Renders a single frontmatter field or recursive sub-structure. */ const FrontmatterRow: React.FC<{ field: string; value: FrontmatterValue }> = ({ field, value }) => { if (isFrontmatterMap(value)) { const subEntries = Object.entries(value); return (
{field}:
{subEntries.map(([k, v]) => ( ))}
); } if (Array.isArray(value)) { const isArrayOfMaps = value.some((v) => typeof v === 'object' && v !== null); if (isArrayOfMaps) { return (
{field}:
{value.map((item, i) => (
{isFrontmatterMap(item) ? ( Object.entries(item).map(([k, v]) => ( )) ) : ( {typeof item === 'string' ? item : String(item)} )}
))}
); } return (
{field}: {value.map((v, i) => ( {typeof v === 'string' ? v : String(v)} ))}
); } return (
{field}: {value}
); }; /** * Renders YAML frontmatter as a styled metadata card. */ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }) => { const entries = Object.entries(frontmatter); if (entries.length === 0) return null; return (
{entries.map(([key, value]) => ( ))}
); }; /** Viewport offset (px) the legacy action cluster pins to while stuck (`top-3`). */ const STICKY_ACTIONS_TOP_PX = 12; interface StickyActionsBox { readonly width: number; readonly height: number; readonly marginTop: number; readonly marginRight: number; } /** * Legacy top-right action cluster, pinned while the document scrolls. * * The cluster must not be a sticky float: when float-avoiding siblings (the * `overflow-x-auto` table and code wrappers) reflow, Firefox lays them out * around the float's *stuck* position, so annotating a long table while * scrolled squeezed it to a sliver. A static float spacer reserves the title's * wrap space instead, and the cluster sticks inside a zero-height lane. Both * copy the cluster's measured box and margins, so host CSS that restyles * `[data-sticky-actions]` keeps its effect. */ const StickyActionsLane: React.FC<{ className: string; children: React.ReactNode }> = ({ className, children }) => { const actionsRef = useRef(null); const [box, setBox] = useState(null); useLayoutEffect(() => { const el = actionsRef.current; if (!el) return; const measure = () => { const style = window.getComputedStyle(el); const next: StickyActionsBox = { width: el.offsetWidth, height: el.offsetHeight, marginTop: Number.parseFloat(style.marginTop) || 0, marginRight: Number.parseFloat(style.marginRight) || 0, }; setBox((prev) => prev && prev.width === next.width && prev.height === next.height && prev.marginTop === next.marginTop && prev.marginRight === next.marginRight ? prev : next); }; measure(); const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure); observer?.observe(el); // Breakpoint-only margin changes (lg/xl) don't resize the cluster. window.addEventListener('resize', measure); return () => { observer?.disconnect(); window.removeEventListener('resize', measure); }; }, []); return ( <>
{children}
{/* After the lane: the lane is a flex box, so a float placed before it would narrow it. */}