Files
backnotprop__plannotator/packages/ui/components/Viewer.tsx
T
Michael Ramos e5f3cfffbb feat: Obsidian vault browser sidebar tab (#209)
* feat: add Obsidian vault browser sidebar tab (#207)

Add a third sidebar tab that lets users browse their Obsidian vault's
file tree, click any markdown file to open and annotate it as part of
plan feedback. Includes wikilink support for navigating between vault
files.

- New /api/reference/obsidian/files and /api/reference/obsidian/doc endpoints
- useVaultBrowser hook and VaultBrowser sidebar component
- Wikilink [[filename]] and [[filename|display]] detection in Viewer
- "Vault Browser" toggle in Settings > Obsidian
- Bare filename search for wikilink resolution within vault

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update docs and clear vault state on disable

- Add new reference endpoints to CLAUDE.md API table
- Update stale doc comments referencing "two tabs" in sidebar files
- Clear vaultPath and activeFile when vault browser is disabled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: simplify vault browser prop passing and derived state

- Move VaultNode to packages/ui/types.ts (shared type location)
- Pass useVaultBrowser return as single object prop through SidebarContainer
- Replace showVaultTab/vaultPath useState with useMemo derivations
- Extract buildVaultDocUrl helper to deduplicate URL template
- Wrap onVaultFetchTree in useCallback
- Fix toggle switch size to match existing toggles (h-6 w-11)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:32:59 -08:00

1243 lines
46 KiB
TypeScript

import React, { useRef, useState, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react';
import Highlighter from '@plannotator/web-highlighter';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css';
import { Block, Annotation, AnnotationType, EditorMode, type ImageAttachment } from '../types';
import { Frontmatter } from '../utils/parser';
import { AnnotationToolbar } from './AnnotationToolbar';
import { TaterSpriteSitting } from './TaterSpriteSitting';
import { AttachmentsButton } from './AttachmentsButton';
import { MermaidBlock } from './MermaidBlock';
import { getIdentity } from '../utils/identity';
import { PlanDiffBadge } from './plan-diff/PlanDiffBadge';
interface ViewerProps {
blocks: Block[];
markdown: string;
frontmatter?: Frontmatter | null;
annotations: Annotation[];
onAddAnnotation: (ann: Annotation) => void;
onSelectAnnotation: (id: string | null) => void;
selectedAnnotationId: string | null;
mode: EditorMode;
taterMode: boolean;
globalAttachments?: ImageAttachment[];
onAddGlobalAttachment?: (image: ImageAttachment) => void;
onRemoveGlobalAttachment?: (path: string) => void;
repoInfo?: { display: string; branch?: string } | null;
stickyActions?: boolean;
onOpenLinkedDoc?: (path: string) => void;
linkedDocInfo?: { filepath: string; onBack: () => void; label?: string } | null;
// Plan diff props
planDiffStats?: { additions: number; deletions: number; modifications: number } | null;
isPlanDiffActive?: boolean;
onPlanDiffToggle?: () => void;
hasPreviousVersion?: boolean;
/** Show amber "Demo" badge (portal mode, no shared content loaded) */
showDemoBadge?: boolean;
}
export interface ViewerHandle {
removeHighlight: (id: string) => void;
clearAllHighlights: () => void;
applySharedAnnotations: (annotations: Annotation[]) => void;
}
/**
* 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 (
<div className="mt-4 mb-6 p-4 bg-muted/30 border border-border/50 rounded-lg">
<div className="grid gap-2 text-sm">
{entries.map(([key, value]) => (
<div key={key} className="flex gap-2">
<span className="font-medium text-muted-foreground min-w-[80px]">{key}:</span>
<span className="text-foreground">
{Array.isArray(value) ? (
<span className="flex flex-wrap gap-1">
{value.map((v, i) => (
<span key={i} className="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-xs">
{v}
</span>
))}
</span>
) : (
value
)}
</span>
</div>
))}
</div>
</div>
);
};
export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
blocks,
markdown,
frontmatter,
annotations,
onAddAnnotation,
onSelectAnnotation,
selectedAnnotationId,
mode,
taterMode,
globalAttachments = [],
onAddGlobalAttachment,
onRemoveGlobalAttachment,
repoInfo,
stickyActions = true,
planDiffStats,
isPlanDiffActive,
onPlanDiffToggle,
hasPreviousVersion,
showDemoBadge,
onOpenLinkedDoc,
linkedDocInfo,
}, ref) => {
const [copied, setCopied] = useState(false);
const [showGlobalCommentInput, setShowGlobalCommentInput] = useState(false);
const [globalCommentValue, setGlobalCommentValue] = useState('');
const globalCommentInputRef = useRef<HTMLTextAreaElement>(null);
const handleCopyPlan = async () => {
try {
await navigator.clipboard.writeText(markdown);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.error('Failed to copy:', e);
}
};
const handleAddGlobalComment = () => {
if (!globalCommentValue.trim()) return;
const newAnnotation: Annotation = {
id: `global-${Date.now()}`,
blockId: '',
startOffset: 0,
endOffset: 0,
type: AnnotationType.GLOBAL_COMMENT,
text: globalCommentValue.trim(),
originalText: '',
createdA: Date.now(),
author: getIdentity(),
};
onAddAnnotation(newAnnotation);
setGlobalCommentValue('');
setShowGlobalCommentInput(false);
};
useEffect(() => {
if (showGlobalCommentInput) {
globalCommentInputRef.current?.focus();
}
}, [showGlobalCommentInput]);
const containerRef = useRef<HTMLDivElement>(null);
const highlighterRef = useRef<Highlighter | null>(null);
const modeRef = useRef<EditorMode>(mode);
const onAddAnnotationRef = useRef(onAddAnnotation);
const pendingSourceRef = useRef<any>(null);
const [toolbarState, setToolbarState] = useState<{
element: HTMLElement;
source: any;
selectionText: string;
initialStep?: 'menu' | 'input';
initialType?: AnnotationType;
} | null>(null);
const [hoveredCodeBlock, setHoveredCodeBlock] = useState<{ block: Block; element: HTMLElement } | null>(null);
const [isCodeBlockToolbarExiting, setIsCodeBlockToolbarExiting] = useState(false);
const [isCodeBlockToolbarLocked, setIsCodeBlockToolbarLocked] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const stickySentinelRef = useRef<HTMLDivElement>(null);
const [isStuck, setIsStuck] = useState(false);
// Detect when sticky action bar is "stuck" to show card background
useEffect(() => {
if (!stickyActions || !stickySentinelRef.current) return;
const scrollContainer = document.querySelector('main');
const observer = new IntersectionObserver(
([entry]) => setIsStuck(!entry.isIntersecting),
{ root: scrollContainer, threshold: 0 }
);
observer.observe(stickySentinelRef.current);
return () => observer.disconnect();
}, [stickyActions]);
// Keep refs in sync with props
useEffect(() => {
modeRef.current = mode;
}, [mode]);
useEffect(() => {
onAddAnnotationRef.current = onAddAnnotation;
}, [onAddAnnotation]);
// Cmd+C / Ctrl+C keyboard shortcut for copying selected text
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
// Check for Cmd+C (Mac) or Ctrl+C (Windows/Linux)
if ((e.metaKey || e.ctrlKey) && e.key === 'c') {
// Don't intercept if typing in an input/textarea
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
// If we have an active selection with captured text, use that
if (toolbarState?.selectionText) {
e.preventDefault();
try {
await navigator.clipboard.writeText(toolbarState.selectionText);
} catch (err) {
console.error('Failed to copy:', err);
}
}
// Otherwise let the browser handle default copy behavior
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [toolbarState]);
// Helper to create annotation from highlighter source
const createAnnotationFromSource = (
highlighter: Highlighter,
source: any,
type: AnnotationType,
text?: string,
images?: ImageAttachment[]
) => {
const doms = highlighter.getDoms(source.id);
let blockId = '';
let startOffset = 0;
if (doms?.length > 0) {
const el = doms[0] as HTMLElement;
let parent = el.parentElement;
while (parent && !parent.dataset.blockId) {
parent = parent.parentElement;
}
if (parent?.dataset.blockId) {
blockId = parent.dataset.blockId;
const blockText = parent.textContent || '';
const beforeText = blockText.split(source.text)[0];
startOffset = beforeText?.length || 0;
}
}
const newAnnotation: Annotation = {
id: source.id,
blockId,
startOffset,
endOffset: startOffset + source.text.length,
type,
text,
originalText: source.text,
createdA: Date.now(),
author: getIdentity(),
startMeta: source.startMeta,
endMeta: source.endMeta,
images,
};
if (type === AnnotationType.DELETION) {
highlighter.addClass('deletion', source.id);
} else if (type === AnnotationType.COMMENT) {
highlighter.addClass('comment', source.id);
}
onAddAnnotationRef.current(newAnnotation);
};
// Helper to find text in DOM and create a range
const findTextInDOM = useCallback((searchText: string): Range | null => {
if (!containerRef.current) return null;
const walker = document.createTreeWalker(
containerRef.current,
NodeFilter.SHOW_TEXT,
null
);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const text = node.textContent || '';
const index = text.indexOf(searchText);
if (index !== -1) {
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + searchText.length);
return range;
}
}
// Try across multiple text nodes for multi-line content
const fullText = containerRef.current.textContent || '';
const searchIndex = fullText.indexOf(searchText);
if (searchIndex === -1) return null;
// Use Selection API to find and select the text
const selection = window.getSelection();
if (!selection) return null;
// Reset walker
const walker2 = document.createTreeWalker(
containerRef.current,
NodeFilter.SHOW_TEXT,
null
);
let charCount = 0;
let startNode: Text | null = null;
let startOffset = 0;
let endNode: Text | null = null;
let endOffset = 0;
while ((node = walker2.nextNode() as Text | null)) {
const nodeLength = node.textContent?.length || 0;
if (!startNode && charCount + nodeLength > searchIndex) {
startNode = node;
startOffset = searchIndex - charCount;
}
if (startNode && charCount + nodeLength >= searchIndex + searchText.length) {
endNode = node;
endOffset = searchIndex + searchText.length - charCount;
break;
}
charCount += nodeLength;
}
if (startNode && endNode) {
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
return range;
}
return null;
}, []);
useImperativeHandle(ref, () => ({
removeHighlight: (id: string) => {
// Try highlighter first (for regular text selections)
highlighterRef.current?.remove(id);
// Handle manually created highlights (may be multiple marks with same ID)
const manualHighlights = containerRef.current?.querySelectorAll(`[data-bind-id="${id}"]`);
manualHighlights?.forEach(el => {
const parent = el.parentNode;
// Check if this is a code block annotation (parent is <code> element)
if (parent && parent.nodeName === 'CODE') {
// For code blocks, we need to restore the plain text and re-highlight
const codeEl = parent as HTMLElement;
const plainText = el.textContent || '';
codeEl.textContent = plainText;
// Re-apply syntax highlighting
const block = blocks.find(b => b.id === codeEl.closest('[data-block-id]')?.getAttribute('data-block-id'));
if (block?.language) {
codeEl.className = `hljs font-mono language-${block.language}`;
hljs.highlightElement(codeEl);
}
} else {
// For regular text, unwrap the mark
while (el.firstChild) {
parent?.insertBefore(el.firstChild, el);
}
}
el.remove();
});
},
clearAllHighlights: () => {
// Clear all manual highlights (shared annotations and code blocks)
const manualHighlights = containerRef.current?.querySelectorAll('[data-bind-id]');
manualHighlights?.forEach(el => {
const parent = el.parentNode;
while (el.firstChild) {
parent?.insertBefore(el.firstChild, el);
}
el.remove();
});
// Clear web-highlighter highlights
const webHighlights = containerRef.current?.querySelectorAll('.annotation-highlight');
webHighlights?.forEach(el => {
const parent = el.parentNode;
while (el.firstChild) {
parent?.insertBefore(el.firstChild, el);
}
el.remove();
});
},
applySharedAnnotations: (sharedAnnotations: Annotation[]) => {
const highlighter = highlighterRef.current;
if (!highlighter || !containerRef.current) return;
sharedAnnotations.forEach(ann => {
// Skip if already highlighted
const existingDoms = highlighter.getDoms(ann.id);
if (existingDoms && existingDoms.length > 0) return;
// Also skip if manually highlighted
const existingManual = containerRef.current?.querySelector(`[data-bind-id="${ann.id}"]`);
if (existingManual) return;
// Find the text in the DOM
const range = findTextInDOM(ann.originalText);
if (!range) {
console.warn(`Could not find text for annotation ${ann.id}: "${ann.originalText.slice(0, 50)}..."`);
return;
}
try {
// Multi-mark approach: wrap each text node portion separately
// This avoids destructive extractContents() that breaks DOM structure
const textNodes: { node: Text; start: number; end: number }[] = [];
// Collect all text nodes within the range
const walker = document.createTreeWalker(
range.commonAncestorContainer.nodeType === Node.TEXT_NODE
? range.commonAncestorContainer.parentNode!
: range.commonAncestorContainer,
NodeFilter.SHOW_TEXT,
null
);
let node: Text | null;
let inRange = false;
while ((node = walker.nextNode() as Text | null)) {
// Check if this node is the start container
if (node === range.startContainer) {
inRange = true;
const start = range.startOffset;
const end = node === range.endContainer ? range.endOffset : node.length;
if (end > start) {
textNodes.push({ node, start, end });
}
if (node === range.endContainer) break;
continue;
}
// Check if this node is the end container
if (node === range.endContainer) {
if (inRange) {
const end = range.endOffset;
if (end > 0) {
textNodes.push({ node, start: 0, end });
}
}
break;
}
// Node is fully within range
if (inRange && node.length > 0) {
textNodes.push({ node, start: 0, end: node.length });
}
}
// If we only have one text node and it's fully contained, use simple approach
if (textNodes.length === 0) {
console.warn(`No text nodes found for annotation ${ann.id}`);
return;
}
// Wrap each text node portion with its own mark (process in reverse to avoid offset issues)
textNodes.reverse().forEach(({ node, start, end }) => {
try {
const nodeRange = document.createRange();
nodeRange.setStart(node, start);
nodeRange.setEnd(node, end);
const mark = document.createElement('mark');
mark.className = 'annotation-highlight';
mark.dataset.bindId = ann.id;
if (ann.type === AnnotationType.DELETION) {
mark.classList.add('deletion');
} else if (ann.type === AnnotationType.COMMENT) {
mark.classList.add('comment');
}
// surroundContents works reliably for single text node ranges
nodeRange.surroundContents(mark);
// Make it clickable
mark.addEventListener('click', () => {
onSelectAnnotation(ann.id);
});
} catch (e) {
console.warn(`Failed to wrap text node for annotation ${ann.id}:`, e);
}
});
} catch (e) {
console.warn(`Failed to apply highlight for annotation ${ann.id}:`, e);
}
});
}
}), [findTextInDOM, onSelectAnnotation]);
useEffect(() => {
if (!containerRef.current) return;
const highlighter = new Highlighter({
$root: containerRef.current,
exceptSelectors: ['.annotation-toolbar', 'button'],
wrapTag: 'mark',
style: { className: 'annotation-highlight' }
});
highlighterRef.current = highlighter;
highlighter.on(Highlighter.event.CREATE, ({ sources }: { sources: any[] }) => {
if (sources.length > 0) {
const source = sources[0];
const doms = highlighter.getDoms(source.id);
if (doms?.length > 0) {
// Clean up previous pending highlight if exists
if (pendingSourceRef.current) {
highlighter.remove(pendingSourceRef.current.id);
pendingSourceRef.current = null;
}
if (modeRef.current === 'redline') {
// Auto-delete in redline mode
createAnnotationFromSource(highlighter, source, AnnotationType.DELETION);
window.getSelection()?.removeAllRanges();
} else if (modeRef.current === 'comment') {
// Comment mode - show input directly
const selectionText = source.text;
pendingSourceRef.current = source;
setToolbarState({
element: doms[0] as HTMLElement,
source,
selectionText,
initialStep: 'input',
initialType: AnnotationType.COMMENT,
});
} else {
// Selection mode - show toolbar menu
const selectionText = source.text;
pendingSourceRef.current = source;
setToolbarState({ element: doms[0] as HTMLElement, source, selectionText });
}
}
}
});
highlighter.on(Highlighter.event.CLICK, ({ id }: { id: string }) => {
onSelectAnnotation(id);
});
highlighter.run();
return () => highlighter.dispose();
}, [onSelectAnnotation]);
useEffect(() => {
const highlighter = highlighterRef.current;
if (!highlighter) return;
annotations.forEach(ann => {
try {
const doms = highlighter.getDoms(ann.id);
if (doms?.length > 0) {
if (ann.type === AnnotationType.DELETION) {
highlighter.addClass('deletion', ann.id);
} else if (ann.type === AnnotationType.COMMENT) {
highlighter.addClass('comment', ann.id);
}
}
} catch (e) {}
});
}, [annotations]);
const handleAnnotate = (type: AnnotationType, text?: string, images?: ImageAttachment[]) => {
const highlighter = highlighterRef.current;
if (!toolbarState || !highlighter) return;
createAnnotationFromSource(highlighter, toolbarState.source, type, text, images);
pendingSourceRef.current = null;
setToolbarState(null);
window.getSelection()?.removeAllRanges();
};
const handleToolbarClose = () => {
if (toolbarState && highlighterRef.current) {
highlighterRef.current.remove(toolbarState.source.id);
}
pendingSourceRef.current = null;
setToolbarState(null);
window.getSelection()?.removeAllRanges();
};
const handleCodeBlockAnnotate = (type: AnnotationType, text?: string, images?: ImageAttachment[]) => {
const highlighter = highlighterRef.current;
if (!hoveredCodeBlock || !highlighter) return;
// Find the code element inside the pre
const codeEl = hoveredCodeBlock.element.querySelector('code');
if (!codeEl) return;
// Use highlighter.fromRange which triggers CREATE event internally
// We need to handle this synchronously, so we'll create the annotation directly
const id = `codeblock-${Date.now()}`;
const codeText = codeEl.textContent || '';
// Instead of using surroundContents (which breaks with syntax-highlighted code),
// we replace the innerHTML entirely with a mark wrapper containing the plain text
const wrapper = document.createElement('mark');
wrapper.className = 'annotation-highlight';
wrapper.dataset.bindId = id;
wrapper.textContent = codeText;
// Add the appropriate class
if (type === AnnotationType.DELETION) {
wrapper.classList.add('deletion');
} else if (type === AnnotationType.COMMENT) {
wrapper.classList.add('comment');
}
// Replace code element's content with the wrapper
codeEl.innerHTML = '';
codeEl.appendChild(wrapper);
// Create the annotation
const newAnnotation: Annotation = {
id,
blockId: hoveredCodeBlock.block.id,
startOffset: 0,
endOffset: codeText.length,
type,
text,
originalText: codeText,
createdA: Date.now(),
author: getIdentity(),
images,
};
onAddAnnotationRef.current(newAnnotation);
// Clear selection
window.getSelection()?.removeAllRanges();
setHoveredCodeBlock(null);
setIsCodeBlockToolbarLocked(false);
};
const handleCodeBlockToolbarClose = () => {
setHoveredCodeBlock(null);
setIsCodeBlockToolbarLocked(false);
};
return (
<div className="relative z-50 w-full max-w-[832px] 2xl:max-w-5xl">
{taterMode && <TaterSpriteSitting />}
<article
ref={containerRef}
className={`w-full max-w-[832px] 2xl:max-w-5xl bg-card rounded-xl shadow-xl p-5 md:p-8 lg:p-10 xl:p-12 relative ${
linkedDocInfo ? 'border-2 border-primary' : 'border border-border/50'
}`}
>
{/* Repo info + plan diff badge + demo badge + linked doc badge - top left */}
{(repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo) && (
<div className="absolute top-3 left-3 md:top-4 md:left-5 flex flex-col items-start gap-1 text-[9px] text-muted-foreground/50 font-mono">
{repoInfo && !linkedDocInfo && (
<div className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 bg-muted/50 rounded truncate max-w-[140px]" title={repoInfo.display}>
{repoInfo.display}
</span>
{repoInfo.branch && (
<span className="px-1.5 py-0.5 bg-muted/30 rounded max-w-[120px] flex items-center gap-1 overflow-hidden" title={repoInfo.branch}>
<svg className="w-2.5 h-2.5 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
</svg>
<span className="truncate">{repoInfo.branch}</span>
</span>
)}
</div>
)}
{onPlanDiffToggle && !linkedDocInfo && (
<PlanDiffBadge
stats={planDiffStats ?? null}
isActive={isPlanDiffActive ?? false}
onToggle={onPlanDiffToggle}
hasPreviousVersion={hasPreviousVersion ?? false}
/>
)}
{showDemoBadge && !linkedDocInfo && (
<span className="px-1.5 py-0.5 rounded text-[9px] font-mono bg-amber-500/15 text-amber-600 dark:text-amber-400">
Demo
</span>
)}
{linkedDocInfo && (
<div className="flex items-center gap-1.5">
<button
onClick={linkedDocInfo.onBack}
className="px-1.5 py-0.5 bg-primary/10 text-primary rounded hover:bg-primary/20 transition-colors flex items-center gap-1"
>
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
plan
</button>
<span className="px-1.5 py-0.5 bg-primary/10 text-primary/80 rounded">
{linkedDocInfo.label || 'Linked File'}
</span>
<span
className="px-1.5 py-0.5 bg-muted/50 text-muted-foreground rounded truncate max-w-[200px]"
title={linkedDocInfo.filepath}
>
{linkedDocInfo.filepath.split('/').pop()}
</span>
</div>
)}
</div>
)}
{/* Sentinel for sticky detection */}
{stickyActions && <div ref={stickySentinelRef} className="h-0 w-0 float-right" aria-hidden="true" />}
{/* Header buttons - top right */}
<div className={`${stickyActions ? 'sticky top-3' : ''} z-30 float-right flex items-start gap-2 rounded-lg p-2 transition-colors duration-150 ${isStuck ? 'bg-card/95 backdrop-blur-sm shadow-sm' : ''} -mr-4 -mt-4 md:-mr-5 md:-mt-5 lg:-mr-7 lg:-mt-7 xl:-mr-9 xl:-mt-9`}>
{/* Attachments button */}
{onAddGlobalAttachment && onRemoveGlobalAttachment && (
<AttachmentsButton
images={globalAttachments}
onAdd={onAddGlobalAttachment}
onRemove={onRemoveGlobalAttachment}
variant="toolbar"
/>
)}
{/* Global comment button/input */}
{showGlobalCommentInput ? (
<form
onSubmit={(e) => {
e.preventDefault();
handleAddGlobalComment();
}}
className="flex items-start gap-1.5 bg-muted/80 rounded-md p-1"
>
<textarea
ref={globalCommentInputRef}
rows={1}
className="bg-transparent text-xs min-w-40 md:min-w-56 max-w-80 max-h-32 placeholder:text-muted-foreground resize-none px-2 py-1.5 focus:outline-none"
style={{ fieldSizing: 'content' } as React.CSSProperties}
placeholder="Add a global comment..."
value={globalCommentValue}
onChange={(e) => setGlobalCommentValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setShowGlobalCommentInput(false);
setGlobalCommentValue('');
}
// Enter to submit, Shift+Enter for newline
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
if (globalCommentValue.trim()) {
handleAddGlobalComment();
}
}
}}
/>
<button
type="submit"
disabled={!globalCommentValue.trim()}
className="self-start px-2 py-1.5 text-xs font-medium rounded bg-secondary text-secondary-foreground hover:opacity-90 disabled:opacity-50 transition-all"
>
Add
</button>
<button
type="button"
onClick={() => {
setShowGlobalCommentInput(false);
setGlobalCommentValue('');
}}
className="self-start p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
) : (
<button
onClick={() => setShowGlobalCommentInput(true)}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
title="Add global comment"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
<span className="hidden md:inline">Global comment</span>
</button>
)}
{/* Copy plan/file button */}
<button
onClick={handleCopyPlan}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
title={copied ? 'Copied!' : linkedDocInfo ? 'Copy file' : 'Copy plan'}
>
{copied ? (
<>
<svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="hidden md:inline">Copied!</span>
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span className="hidden md:inline">{linkedDocInfo ? 'Copy file' : 'Copy plan'}</span>
</>
)}
</button>
</div>
{frontmatter && <FrontmatterCard frontmatter={frontmatter} />}
{blocks.map(block => (
block.type === 'code' && block.language === 'mermaid' ? (
<MermaidBlock key={block.id} block={block} />
) : block.type === 'code' ? (
<CodeBlock
key={block.id}
block={block}
onHover={(element) => {
// 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) {
setHoveredCodeBlock({ block, element });
}
}}
onLeave={() => {
// Delay then start exit animation
hoverTimeoutRef.current = setTimeout(() => {
setIsCodeBlockToolbarExiting(true);
// After exit animation, unmount
setTimeout(() => {
setHoveredCodeBlock(null);
setIsCodeBlockToolbarExiting(false);
}, 150);
}, 100);
}}
isHovered={hoveredCodeBlock?.block.id === block.id}
/>
) : (
<BlockRenderer key={block.id} block={block} onOpenLinkedDoc={onOpenLinkedDoc} />
)
))}
{/* Text selection toolbar */}
{toolbarState && (
<AnnotationToolbar
element={toolbarState.element}
positionMode="center-above"
onAnnotate={handleAnnotate}
onClose={handleToolbarClose}
copyText={toolbarState.selectionText}
closeOnScrollOut
initialStep={toolbarState.initialStep}
initialType={toolbarState.initialType}
/>
)}
{/* Code block hover toolbar */}
{hoveredCodeBlock && !toolbarState && (
<AnnotationToolbar
element={hoveredCodeBlock.element}
positionMode="top-right"
onAnnotate={handleCodeBlockAnnotate}
onClose={handleCodeBlockToolbarClose}
isExiting={isCodeBlockToolbarExiting}
onMouseEnter={() => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
setIsCodeBlockToolbarExiting(false);
}}
onMouseLeave={() => {
// Don't close if toolbar is locked (in input mode)
if (isCodeBlockToolbarLocked) return;
hoverTimeoutRef.current = setTimeout(() => {
setIsCodeBlockToolbarExiting(true);
setTimeout(() => {
setHoveredCodeBlock(null);
setIsCodeBlockToolbarExiting(false);
}, 150);
}, 100);
}}
onLockChange={setIsCodeBlockToolbarLocked}
/>
)}
</article>
</div>
);
});
/**
* Renders inline markdown: **bold**, *italic*, `code`, [links](url)
*/
const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string) => void }> = ({ text, onOpenLinkedDoc }) => {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
while (remaining.length > 0) {
// Bold: **text**
let match = remaining.match(/^\*\*(.+?)\*\*/);
if (match) {
parts.push(<strong key={key++} className="font-semibold">{match[1]}</strong>);
remaining = remaining.slice(match[0].length);
continue;
}
// Italic: *text*
match = remaining.match(/^\*(.+?)\*/);
if (match) {
parts.push(<em key={key++}>{match[1]}</em>);
remaining = remaining.slice(match[0].length);
continue;
}
// Inline code: `code`
match = remaining.match(/^`([^`]+)`/);
if (match) {
parts.push(
<code key={key++} className="px-1.5 py-0.5 rounded bg-muted text-sm font-mono">
{match[1]}
</code>
);
remaining = remaining.slice(match[0].length);
continue;
}
// Wikilinks: [[filename]] or [[filename|display text]]
match = remaining.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/);
if (match) {
const target = match[1].trim();
const display = match[2]?.trim() || target;
const targetPath = /\.mdx?$/i.test(target) ? target : `${target}.md`;
if (onOpenLinkedDoc) {
parts.push(
<a
key={key++}
href={targetPath}
onClick={(e) => {
e.preventDefault();
onOpenLinkedDoc(targetPath);
}}
className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer"
title={`Open: ${target}`}
>
{display}
<svg className="w-3 h-3 opacity-50 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
</a>
);
} else {
parts.push(
<span key={key++} className="text-primary">{display}</span>
);
}
remaining = remaining.slice(match[0].length);
continue;
}
// Links: [text](url)
match = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (match) {
const linkText = match[1];
const linkUrl = match[2];
const isLocalMd = /\.md(x?)$/i.test(linkUrl) &&
!linkUrl.startsWith('http://') &&
!linkUrl.startsWith('https://');
if (isLocalMd && onOpenLinkedDoc) {
parts.push(
<a
key={key++}
href={linkUrl}
onClick={(e) => {
e.preventDefault();
onOpenLinkedDoc(linkUrl);
}}
className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer"
title={`Open: ${linkUrl}`}
>
{linkText}
<svg className="w-3 h-3 opacity-50 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
</a>
);
} else if (isLocalMd) {
// No handler — render as plain link (e.g., in shared/portal views)
parts.push(
<a
key={key++}
href={linkUrl}
className="text-primary underline underline-offset-2 hover:text-primary/80"
>
{linkText}
</a>
);
} else {
parts.push(
<a
key={key++}
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:text-primary/80"
>
{linkText}
</a>
);
}
remaining = remaining.slice(match[0].length);
continue;
}
// Find next special character or consume one regular character
const nextSpecial = remaining.slice(1).search(/[\*`\[]/);
if (nextSpecial === -1) {
parts.push(remaining);
break;
} else {
parts.push(remaining.slice(0, nextSpecial + 1));
remaining = remaining.slice(nextSpecial + 1);
}
}
return <>{parts}</>;
};
const parseTableContent = (content: string): { headers: string[]; rows: string[][] } => {
const lines = content.split('\n').filter(line => line.trim());
if (lines.length === 0) return { headers: [], rows: [] };
const parseRow = (line: string): string[] => {
// Remove leading/trailing pipes and split by |
return line
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map(cell => cell.trim());
};
const headers = parseRow(lines[0]);
const rows: string[][] = [];
// Skip the separator line (contains dashes) and parse data rows
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
// Skip separator lines (contain only dashes, pipes, colons, spaces)
if (/^[\|\-:\s]+$/.test(line)) continue;
rows.push(parseRow(line));
}
return { headers, rows };
};
const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) => void }> = ({ block, onOpenLinkedDoc }) => {
switch (block.type) {
case 'heading':
const Tag = `h${block.level || 1}` as keyof JSX.IntrinsicElements;
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 <Tag className={styles} data-block-id={block.id} data-block-type="heading"><InlineMarkdown text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} /></Tag>;
case 'blockquote':
return (
<blockquote
className="border-l-2 border-primary/50 pl-4 my-4 text-muted-foreground italic"
data-block-id={block.id}
>
<InlineMarkdown text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} />
</blockquote>
);
case 'list-item': {
const indent = (block.level || 0) * 1.25; // 1.25rem per level
const isCheckbox = block.checked !== undefined;
return (
<div
className="flex gap-3 my-1.5"
data-block-id={block.id}
style={{ marginLeft: `${indent}rem` }}
>
<span className="select-none shrink-0 flex items-center">
{isCheckbox ? (
block.checked ? (
<svg className="w-4 h-4 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
<svg className="w-4 h-4 text-muted-foreground/50" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="9" />
</svg>
)
) : (
<span className="text-primary/60">
{(block.level || 0) === 0 ? '•' : (block.level || 0) === 1 ? '◦' : '▪'}
</span>
)}
</span>
<span className={`text-sm leading-relaxed ${isCheckbox && block.checked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`}>
<InlineMarkdown text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} />
</span>
</div>
);
}
case 'code':
return <CodeBlock block={block} />;
case 'table': {
const { headers, rows } = parseTableContent(block.content);
return (
<div className="my-4 overflow-x-auto" data-block-id={block.id}>
<table className="min-w-full border-collapse text-sm">
<thead>
<tr className="border-b border-border">
{headers.map((header, i) => (
<th
key={i}
className="px-3 py-2 text-left font-semibold text-foreground/90 bg-muted/30"
>
<InlineMarkdown text={header} onOpenLinkedDoc={onOpenLinkedDoc} />
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIdx) => (
<tr key={rowIdx} className="border-b border-border/50 hover:bg-muted/20">
{row.map((cell, cellIdx) => (
<td key={cellIdx} className="px-3 py-2 text-foreground/80">
<InlineMarkdown text={cell} onOpenLinkedDoc={onOpenLinkedDoc} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
case 'hr':
return <hr className="border-border/30 my-8" data-block-id={block.id} />;
default:
return (
<p
className="mb-4 leading-relaxed text-foreground/90 text-[15px]"
data-block-id={block.id}
>
<InlineMarkdown text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} />
</p>
);
}
};
interface CodeBlockProps {
block: Block;
onHover: (element: HTMLElement) => void;
onLeave: () => void;
isHovered: boolean;
}
const CodeBlock: React.FC<CodeBlockProps> = ({ block, onHover, onLeave, isHovered }) => {
const [copied, setCopied] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const codeRef = useRef<HTMLElement>(null);
// Highlight code block on mount and when content/language changes
useEffect(() => {
if (codeRef.current) {
// Reset any previous highlighting
codeRef.current.removeAttribute('data-highlighted');
codeRef.current.className = `hljs font-mono${block.language ? ` language-${block.language}` : ''}`;
hljs.highlightElement(codeRef.current);
}
}, [block.content, block.language]);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(block.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}, [block.content]);
const handleMouseEnter = () => {
if (containerRef.current) {
onHover(containerRef.current);
}
};
// Build className for code element
const codeClassName = `hljs font-mono${block.language ? ` language-${block.language}` : ''}`;
return (
<div
ref={containerRef}
className="relative group my-5"
data-block-id={block.id}
onMouseEnter={handleMouseEnter}
onMouseLeave={onLeave}
>
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-muted/80 hover:bg-muted text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity z-10"
title={copied ? 'Copied!' : 'Copy code'}
>
{copied ? (
<svg className="w-4 h-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
<pre className="rounded-lg text-[13px] overflow-x-auto bg-muted/50 border border-border/30">
<code ref={codeRef} className={codeClassName}>{block.content}</code>
</pre>
</div>
);
};