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
` element's children — that is how the
// palette/mode change repaints tokens, and how the first async grammar
// attach lands after load. It also destroys any annotation mark inside the
// fence. Re-paint it here, SYNCHRONOUSLY after the write, so the block ends
// up with both the new theme's tokens and its mark.
//
// Being driven by the swap is also what makes the restore race safe without
// timing: a share/draft restore that painted before the swap is
// re-established in the same task the swap ran in, and one that runs after
// it finds the mark already present and leaves it alone.
useEffect(() => onCodeHighlightSwap((codeEl) => {
const container = containerRef.current;
if (!container || !container.contains(codeEl)) return;
// The swap always clears the element, so a surviving mark means this write
// was not the one that owns this block's contents.
if (codeEl.querySelector('[data-bind-id]')) return;
const codeText = codeEl.textContent ?? '';
if (!codeText) return;
const blockId = codeEl.closest('[data-block-id]')?.getAttribute('data-block-id') ?? '';
// Fenced code is annotated all-or-nothing, so this block's annotations are
// exactly the ones whose originalText is its full text. Share-restored
// annotations arrive with an empty blockId (it is filled in during restore),
// so an unset blockId still counts. The last one wins, matching what
// annotating the same block twice does.
const owner = annotationsRef.current.filter((a) =>
a.type !== AnnotationType.GLOBAL_COMMENT
&& !a.diffContext
&& a.originalText === codeText
&& (a.blockId === blockId || !a.blockId)
&& !removedAnnotationIdsRef.current.has(a.id)
&& !container.querySelector(`[data-bind-id="${a.id}"], [data-highlight-id="${a.id}"]`)
).at(-1);
if (owner) paintCodeBlockMark(codeEl, owner.id, owner.type);
}), []);
// Pinpoint mode: hover + click to select elements
const handlePinpointCodeBlockClick = useCallback((blockId: string, element: HTMLElement) => {
if (readOnlyRef.current) return;
const block = blocks.find((candidate) => candidate.id === blockId);
const codeEl = element.querySelector('code');
if (!block || !codeEl) return;
// In pinpoint mode, apply code block annotation based on current editor mode
if (modeRef.current === 'redline') {
applyCodeBlockAnnotation(blockId, codeEl, AnnotationType.DELETION);
} else if (modeRef.current === 'quickLabel') {
setCodeBlockQuickLabelPicker({
anchorEl: element,
codeBlock: { block, element },
});
} else {
// Show comment popover anchored to the code block
setViewerCommentPopover({
anchorEl: element,
contextText: (codeEl.textContent || '').slice(0, 80),
selectedText: codeEl.textContent || '',
isGlobal: false,
codeBlock: { block, element },
});
}
}, [applyCodeBlockAnnotation, blocks]);
const handleKeyboardCodeBlockAction = useCallback((
blockId: string,
element: HTMLElement,
modeOverride?: EditorMode,
) => {
if (readOnlyRef.current) return;
const block = blocks.find((candidate) => candidate.id === blockId);
const codeEl = element.querySelector('code');
if (!block || !codeEl) return;
const effectiveMode = modeOverride ?? modeRef.current;
if (effectiveMode === 'redline') {
applyCodeBlockAnnotation(blockId, codeEl, AnnotationType.DELETION);
return;
}
if (effectiveMode === 'quickLabel') {
setCodeBlockQuickLabelPicker({
anchorEl: element,
codeBlock: { block, element },
});
return;
}
if (effectiveMode === 'selection') {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
setCodeBlockToolbar({ block, element, activation: 'keyboard' });
return;
}
setViewerCommentPopover({
anchorEl: element,
contextText: (codeEl.textContent || '').slice(0, 80),
selectedText: codeEl.textContent || '',
isGlobal: false,
codeBlock: { block, element },
});
}, [applyCodeBlockAnnotation, blocks]);
const vimModeActive = vimModeEnabled && !authoringOff;
const keyboardCodeBlockToolbarOpen = codeBlockToolbar?.activation === 'keyboard';
const vimBlocked = !!toolbarState
|| !!hookCommentPopover
|| !!viewerCommentPopover
|| !!hookQuickLabelPicker
|| !!codeBlockQuickLabelPicker
|| keyboardCodeBlockToolbarOpen
|| !!isPlanDiffActive
|| !!popoutTable
|| !!lightbox;
const clearPinpointHoverRef = useRef<() => void>(() => {});
const handleVimCommand = useCallback(() => {
clearPinpointHoverRef.current();
}, []);
const vim = useVimSelection({
containerRef,
scrollViewport,
enabled: vimModeActive,
hudEnabled: vimHudEnabled,
blocked: vimBlocked,
activeMode: mode,
contentVersion: blocks,
onHighlightRange: highlightRange,
onCodeBlockAction: handleKeyboardCodeBlockAction,
onMathAction: highlightMathElement,
onHandledCommand: handleVimCommand,
});
// With the composer handed to the host, a pinpoint tap opens it at once.
const hostPinpointRange = useCallback((range: Range) => highlightRange(range, 'comment'), [highlightRange]);
const noHostCodeBlockClick = useCallback(() => {}, []);
const { hoverTarget, clearHover: clearPinpointHover } = usePinpoint({
containerRef,
inputMethod,
// A host composer's draft stays open while the host composes; the next tap replaces it.
enabled: !authoringOff && (hostComposes || (!toolbarState && !hookCommentPopover)) && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false) && !vim.helpOpen,
onSelectRange: hostComposes ? hostPinpointRange : highlightRange,
onCodeBlockClick: hostComposes ? noHostCodeBlockClick : handlePinpointCodeBlockClick,
});
clearPinpointHoverRef.current = clearPinpointHover;
const vimOwnsHudTarget = vimHudEnabled
&& vim.state.phase !== 'inactive'
&& (vim.focused || vim.state.phase === 'action');
const vimOwnsDocumentNavigation = vimModeActive
&& vim.focused
&& vim.state.phase !== 'inactive'
&& vim.state.phase !== 'action';
const legacyVimTarget = !vimHudEnabled
&& (vim.focused || vim.state.phase === 'action')
? vim.activeTarget
: null;
const pinpointOverlayTarget = vimOwnsHudTarget
? null
: (inputMethod === 'pinpoint' ? hoverTarget : null) ?? legacyVimTarget;
useEffect(() => {
if (!authoringOff) return;
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
setCodeBlockToolbar(null);
setIsCodeBlockToolbarExiting(false);
setViewerCommentPopover(null);
setCodeBlockQuickLabelPicker(null);
}, [authoringOff]);
useEffect(() => {
if (!vimOwnsDocumentNavigation) return;
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
setCodeBlockToolbar((current) => (
current?.activation === 'pointer' ? null : current
));
setIsCodeBlockToolbarExiting(false);
}, [vimOwnsDocumentNavigation]);
// Suppress native context menu on touch devices (prevents cut/copy/paste overlay on mobile)
useEffect(() => {
const container = containerRef.current;
if (!container || !isTouchDevice) return;
const handleContextMenu = (e: Event) => {
e.preventDefault();
};
container.addEventListener('contextmenu', handleContextMenu);
return () => container.removeEventListener('contextmenu', handleContextMenu);
}, []);
// Detect when sticky action bar is "stuck" to show card background.
// The IntersectionObserver root must be the actual scroll element — the
// OverlayScrollArea viewport — not the host, which doesn't scroll.
useEffect(() => {
if (!stickyActions) {
setIsStuck(false);
return;
}
if (!stickySentinelRef.current || !scrollViewport) return;
const observer = new IntersectionObserver(
([entry]) => setIsStuck(!entry.isIntersecting),
{ root: getScrollViewportIntersectionRoot(scrollViewport), threshold: 0 }
);
observer.observe(stickySentinelRef.current);
return () => observer.disconnect();
}, [hasViewerAnnotationHeader, stickyActions, scrollViewport]);
useEffect(() => {
const handleHashChange = () => {
lastAutoScrolledHashRef.current = null;
setLocationHash(window.location.hash);
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
const scrollToAnchor = useCallback((hash: string) => {
const anchor = decodeAnchorHash(hash);
if (!anchor) return false;
const container = containerRef.current;
if (!container || !scrollViewport) return false;
const target = document.getElementById(anchor);
if (!target || !container.contains(target)) return false;
const documentHeader = stickyActions
? container.querySelector('[data-viewer-document-header]')
?? container.querySelector('[data-sticky-actions]')
: null;
// The legacy cluster sticks via its lane, so its own `top` is `auto`.
const stickyTop = documentHeader
? documentHeader.hasAttribute('data-viewer-document-header')
? Number.parseFloat(window.getComputedStyle(documentHeader).top || '0') || 0
: STICKY_ACTIONS_TOP_PX
: 0;
const headerOffset = documentHeader
? documentHeader.getBoundingClientRect().height + stickyTop
: 0;
const containerRect = getScrollViewportRect(scrollViewport);
const targetRect = target.getBoundingClientRect();
const relativeTop = targetRect.top - containerRect.top;
const offsetPosition = getScrollViewportTop(scrollViewport) + relativeTop - headerOffset;
scrollViewportTo(scrollViewport, {
top: Math.max(0, offsetPosition),
behavior: 'smooth',
});
return true;
}, [scrollViewport, stickyActions]);
useEffect(() => {
if (!scrollViewport || !locationHash || lastAutoScrolledHashRef.current === locationHash) return;
const timer = window.setTimeout(() => {
if (scrollToAnchor(locationHash)) {
lastAutoScrolledHashRef.current = locationHash;
}
}, 0);
return () => window.clearTimeout(timer);
}, [blocks, locationHash, scrollToAnchor, scrollViewport]);
// Use the native copy event so clipboard writes are synchronous (Safari
// rejects the async navigator.clipboard API outside the user-gesture window).
// web-highlighter clears the DOM selection on mouseup, so the browser has
// nothing to copy by the time Cmd+C fires — we inject the captured text here.
useEffect(() => {
const handleCopy = (e: ClipboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (toolbarState?.selectionText) {
e.preventDefault();
e.clipboardData?.setData('text/plain', toolbarState.selectionText);
}
};
document.addEventListener('copy', handleCopy);
return () => document.removeEventListener('copy', handleCopy);
}, [toolbarState]);
// Imperative handle — delegates to hook, extends removeHighlight for code blocks
useImperativeHandle(ref, () => ({
removeHighlight: (id: string) => {
// The re-highlight below notifies the swap listener, which would happily
// paint this annotation's mark straight back in — the host has not
// dropped it from state yet. Tombstone the id first.
removedAnnotationIdsRef.current.add(id);
// Code block annotations need syntax re-highlighting after removal.
// Must run BEFORE hookRemoveHighlight, which removes the elements.
const manualHighlights = containerRef.current?.querySelectorAll(`[data-bind-id="${id}"]`);
manualHighlights?.forEach(el => {
const parent = el.parentNode;
if (parent && parent.nodeName === 'CODE') {
const codeEl = parent as HTMLElement;
const plainText = el.textContent || '';
el.remove();
codeEl.textContent = plainText;
const block = blocks.find(b => b.id === codeEl.closest('[data-block-id]')?.getAttribute('data-block-id'));
codeEl.className = codeBlockClassName(block?.language);
// Language-less fences stay plain (#1212) — applyHighlight never guesses.
applyHighlight(codeEl, plainText, block?.language, fenceThemeRef.current);
}
});
hookRemoveHighlight(id);
},
clearAllHighlights,
applySharedAnnotations: applyAnnotations,
takeSelection,
}), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks, takeSelection]);
// --- Viewer-specific: code block annotation ---
const handleCodeBlockAnnotate = (type: AnnotationType) => {
if (readOnlyRef.current || !codeBlockToolbar) return;
const codeEl = codeBlockToolbar.element.querySelector('code');
if (!codeEl) return;
applyCodeBlockAnnotation(codeBlockToolbar.block.id, codeEl, type);
setCodeBlockToolbar(null);
};
const handleCodeBlockQuickLabel = (label: QuickLabel) => {
if (readOnlyRef.current || !codeBlockToolbar) return;
const codeEl = codeBlockToolbar.element.querySelector('code');
if (!codeEl) return;
applyCodeBlockAnnotation(
codeBlockToolbar.block.id, codeEl, AnnotationType.COMMENT,
`${label.emoji} ${label.text}`, undefined, true, label.tip
);
setCodeBlockToolbar(null);
};
const handleCodeBlockToolbarClose = () => {
setCodeBlockToolbar(null);
};
// Viewer-specific comment popover handlers (code blocks + global comments)
const handleCodeBlockRequestComment = (initialChar?: string) => {
if (readOnlyRef.current || !codeBlockToolbar) return;
const codeText = codeBlockToolbar.element.querySelector('code')?.textContent || '';
setViewerCommentPopover({
anchorEl: codeBlockToolbar.element,
contextText: codeText.slice(0, 80),
selectedText: codeText,
initialText: initialChar,
isGlobal: false,
codeBlock: codeBlockToolbar,
});
setCodeBlockToolbar(null);
};
const handleViewerCommentSubmit = (
text: string,
images?: ImageAttachment[],
mentions?: readonly string[],
) => {
if (readOnlyRef.current || !viewerCommentPopover) return;
if (viewerCommentPopover.isGlobal) {
const newAnnotation: Annotation = {
// randomUUID, not Date.now(): two comments minted in the same
// millisecond (paste + submit, or a concurrent external write)
// would collide on a timestamp id.
id: generateId('global'),
blockId: '',
startOffset: 0,
endOffset: 0,
type: AnnotationType.GLOBAL_COMMENT,
text: text.trim(),
originalText: '',
createdA: Date.now(),
author: getIdentity(),
images,
...(mentions && mentions.length > 0 ? { mentions } : {}),
};
onAddAnnotation(newAnnotation);
} else if (viewerCommentPopover.codeBlock) {
const codeEl = viewerCommentPopover.codeBlock.element.querySelector('code');
if (codeEl) {
applyCodeBlockAnnotation(
viewerCommentPopover.codeBlock.block.id,
codeEl,
AnnotationType.COMMENT,
text,
images,
undefined,
undefined,
mentions,
);
}
}
setViewerCommentPopover(null);
};
const handleViewerCommentClose = useCallback(() => {
setViewerCommentPopover(null);
}, []);
const commentDraftScope = linkedDocInfo?.filepath ?? sourceInfo ?? markdown.slice(0, 120);
const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir, disableCodePathValidation);
const documentActions = (
<>
{messagePickerInfo && (
)}
{!authoringOff && onAddGlobalAttachment && onRemoveGlobalAttachment && (
)}
{!authoringOff && (
)}
>
);
// The document's diagram blocks, in order: a diagram comment that names
// none of them (an external POST, a deleted fence) is resolved by anchor
// against each, first resolver wins (see diagram/anchorClaims).
const diagramBlockKey = blocks
.filter((b) => b.type === 'code' && (isMermaidLanguage(b.language) || isGraphvizLanguage(b.language)))
.map((b) => b.id)
.join('\n');
const diagramClaims = useMemo(
() => new DiagramAnchorClaims(diagramBlockKey === '' ? [] : diagramBlockKey.split('\n')),
[diagramBlockKey],
);
// With no diagram in the document nobody can resolve a diagram comment:
// it is unanchored, and the highlighter (which skips it) will not say so.
useEffect(() => {
if (diagramBlockKey !== '' || onRestoreReport === undefined) return;
// `!= null`, not `!== undefined`: a nullish anchor is no anchor at all —
// the highlighter restores such a row by text, so it must not be counted
// here as a diagram comment nothing could resolve.
const ids = annotations.filter((ann) => ann.diagramAnchor != null).map((ann) => ann.id);
if (ids.length > 0) onRestoreReport({ attempted: ids, unanchored: ids });
}, [annotations, diagramBlockKey, onRestoreReport]);
return (
{taterMode && }
{/* Legacy badge placement remains byte-for-byte opt-out behavior. */}
{!viewerAnnotationHeader && (repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || openInAppPath) && (
)}
{viewerAnnotationHeader ? (
) : (
<>
{badgeClearance > 0 && }
{stickyActions && }
{answerOnly || hostComposes ? null : stickyActions ? (
{documentActions}
) : (
{documentActions}
)}
>
)}
{frontmatter && <> >}
{!frontmatter && blocks.length > 0 && blocks[0].type !== 'heading' && }
{groupBlocks(blocks).map(group =>
group.type === 'list-group' ? (
(() => {
const indices = computeListIndices(group.blocks);
return (
{group.blocks.map((block, i) => (
setLightbox({ src, alt })}
key={block.id}
block={block}
orderedIndex={indices[i]}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onToggleCheckbox={readOnly ? undefined : onToggleCheckbox}
checkboxOverrides={checkboxOverrides}
githubRepo={linkRepo?.display}
repoHost={linkRepo?.host}
headingAnchorId={headingSlugMap.get(block.id)}
onNavigateAnchor={scrollToAnchor}
/>
))}
);
})()
) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? (
}>
) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? (
}>
) : group.block.type === 'table' ? (
setLightbox({ src, alt })}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
githubRepo={linkRepo?.display}
repoHost={linkRepo?.host}
onNavigateAnchor={scrollToAnchor}
onHover={(element) => {
if (tableHoverTimeoutRef.current) {
clearTimeout(tableHoverTimeoutRef.current);
tableHoverTimeoutRef.current = null;
}
setIsTableToolbarExiting(false);
if (!toolbarState && !hostComposes) {
setHoveredTable({ block: group.block, element });
}
}}
onLeave={() => {
tableHoverTimeoutRef.current = setTimeout(() => {
setIsTableToolbarExiting(true);
setTimeout(() => {
setHoveredTable(null);
setIsTableToolbarExiting(false);
}, 150);
}, 100);
}}
/>
) : group.block.type === 'code' ? (
{
// Clear any pending leave timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
// Cancel exit animation if re-entering
setIsCodeBlockToolbarExiting(false);
// Only show hover toolbar if no selection toolbar is active
if (
!toolbarState
&& !vimOwnsDocumentNavigation
&& !keyboardCodeBlockToolbarOpen
) {
setCodeBlockToolbar({
block: group.block,
element,
activation: 'pointer',
});
}
}}
onLeave={authoringOff || hostComposes || inputMethod === 'pinpoint' ? undefined : () => {
if (keyboardCodeBlockToolbarOpen) return;
// Delay then start exit animation
hoverTimeoutRef.current = setTimeout(() => {
setIsCodeBlockToolbarExiting(true);
// After exit animation, unmount
setTimeout(() => {
setCodeBlockToolbar(null);
setIsCodeBlockToolbarExiting(false);
}, 150);
}, 100);
}}
isHovered={
!authoringOff
&& inputMethod !== 'pinpoint'
&& !vimOwnsDocumentNavigation
&& codeBlockToolbar?.block.id === group.block.id
}
/>
) : (
(() => {
const question = questionIndex.byBlock.get(group.block.id);
const hostCard = question && renderQuestionCard
? renderQuestionCard(question, answersByKey.get(question.question.key))
: undefined;
if (hostCard !== undefined) {
return (
{hostCard}
);
}
const rendered = (
setLightbox({ src, alt })}
key={group.block.id}
block={group.block}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onNavigateAnchor={scrollToAnchor}
onToggleCheckbox={readOnly ? undefined : onToggleCheckbox}
checkboxOverrides={checkboxOverrides}
githubRepo={linkRepo?.display}
repoHost={linkRepo?.host}
headingAnchorId={headingSlugMap.get(group.block.id)}
question={question}
questionTotal={questionIndex.total}
questionAnswer={question ? answersByKey.get(question.question.key) : undefined}
onAnswerQuestion={readOnly || (question && questionReadOnly?.(question)) ? undefined : onAnswerQuestion}
onSaveQuestionAnswer={readOnly || (question && questionReadOnly?.(question)) ? undefined : onSaveQuestionAnswer}
saveQuestionAnswerLabel={saveQuestionAnswerLabel}
renderQuestionFooter={renderQuestionFooter}
questionDecisionRecording={question ? questionDecisionRecording?.(question) : undefined}
questionDecisionScope={questionDecisionScope}
onToggleQuestionDecisionRecording={readOnly || (question && (questionDecisionLocked?.(question) || questionReadOnly?.(question))) ? undefined : onToggleQuestionDecisionRecording}
onOpenQuestionDecision={readOnly || (question && (questionDecisionLocked?.(question) || questionReadOnly?.(question))) ? undefined : onOpenQuestionDecision}
questionStatusTag={questionStatusTag}
questionStatusLabel={questionStatusLabel}
renderQuestionBelowPrompt={renderQuestionBelowPrompt}
/>
);
// `textComments`: a question card's words are not commented on
// (its own controls already are excluded); a box-less wrapper
// keeps its layout.
if (question && textCommentsOnly) {
return (
{rendered}
);
}
return rendered;
})()
)
)}
{/* Text selection toolbar */}
{selectionCommentsOn && !hostComposes && toolbarState && (
)}
{/* Table hover toolbar */}
{hoveredTable && !toolbarState && (
{
setPopoutTable(hoveredTable.block);
setHoveredTable(null);
setIsTableToolbarExiting(false);
if (tableHoverTimeoutRef.current) {
clearTimeout(tableHoverTimeoutRef.current);
tableHoverTimeoutRef.current = null;
}
}}
onMouseEnter={() => {
if (tableHoverTimeoutRef.current) {
clearTimeout(tableHoverTimeoutRef.current);
tableHoverTimeoutRef.current = null;
}
setIsTableToolbarExiting(false);
}}
onMouseLeave={() => {
tableHoverTimeoutRef.current = setTimeout(() => {
setIsTableToolbarExiting(true);
setTimeout(() => {
setHoveredTable(null);
setIsTableToolbarExiting(false);
}, 150);
}, 100);
}}
/>
)}
{/* Code block hover toolbar */}
{!authoringOff
&& codeBlockToolbar
&& !toolbarState
&& !(vimOwnsDocumentNavigation && codeBlockToolbar.activation === 'pointer')
&& (
{
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
setIsCodeBlockToolbarExiting(false);
}}
onMouseLeave={() => {
if (codeBlockToolbar.activation === 'keyboard') return;
hoverTimeoutRef.current = setTimeout(() => {
setIsCodeBlockToolbarExiting(true);
setTimeout(() => {
setCodeBlockToolbar(null);
setIsCodeBlockToolbarExiting(false);
}, 150);
}, 100);
}}
/>
)}
{/* Table popout dialog — portaled into containerRef so annotations */}
{/* can walk into its text nodes the same way they do the inline table. */}
{popoutTable && (
setPopoutTable(null)}
container={containerRef.current}
imageBaseDir={imageBaseDir}
onImageClick={(src, alt) => setLightbox({ src, alt })}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
githubRepo={linkRepo?.display}
repoHost={linkRepo?.host}
onNavigateAnchor={scrollToAnchor}
/>
)}
{/* Pinpoint hover overlay */}
{(inputMethod === 'pinpoint' || vim.activeTarget) && (
)}
{vimModeActive && (
onVimHudKeyPanelChange(false)
: undefined
}
onHudFocusLeave={vim.onHudFocusLeave}
/>
)}
{/* Comment popover — hook handles text selection, Viewer handles global + code block */}
{selectionCommentsOn && !hostComposes && hookCommentPopover && (
)}
{!authoringOff && viewerCommentPopover && (
)}
{/* Quick Label floating picker — hook handles text selection, Viewer handles code blocks */}
{selectionCommentsOn && !hostComposes && hookQuickLabelPicker && (
)}
{!authoringOff && codeBlockQuickLabelPicker && (
{
const codeEl = codeBlockQuickLabelPicker.codeBlock.element.querySelector('code');
if (codeEl) {
applyCodeBlockAnnotation(
codeBlockQuickLabelPicker.codeBlock.block.id, codeEl, AnnotationType.COMMENT,
`${label.emoji} ${label.text}`, undefined, true, label.tip
);
}
setCodeBlockQuickLabelPicker(null);
window.getSelection()?.removeAllRanges();
}}
onDismiss={() => {
setCodeBlockQuickLabelPicker(null);
window.getSelection()?.removeAllRanges();
}}
/>
)}
{/* Image lightbox */}
{lightbox && createPortal(
setLightboxState(null)} />,
document.body
)}
);
});