Document Outline Component
Interactive table of contents with drag-and-drop reordering and section navigation
Overview
The DocumentOutline component provides an interactive document outline (table of contents) with drag-and-drop reordering capabilities. It displays sections in a hierarchical tree structure, supports expansion/collapse, and allows users to navigate between sections or reorder them in the main document.
Key Features
- Hierarchical section tree - Displays parent-child relationships
- Drag-and-drop reordering - Rearrange sections interactively
- Expand/collapse sections - Toggle visibility of subsections
- Word count per section - Quick content overview
- Keyboard navigation support - Full accessibility
- Read-only mode - Navigation-only display
- Total word count - Document statistics
- Smooth animations - Visual feedback for interactions
- Expand/Collapse All - Quick navigation controls
Component API
Props Interface
export interface Section {
id: string;
title: string;
content: string;
children?: string[]; // Array of child section IDs
level?: number;
}
export interface DocumentOutlineProps {
/** Array of sections in the document */
sections: Section[];
/** Callback when sections are reordered */
onReorder: (sections: Section[]) => void;
/** Callback when a section is clicked */
onSectionClick?: (sectionId: string) => void;
/** Disable reordering (read-only mode) */
readOnly?: boolean;
}Props Reference
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
sections | Section[] | Yes | - | Array of section objects with id, title, content, and optional children |
onReorder | (sections: Section[]) => void | Yes | - | Callback fired when sections are reordered via drag-and-drop |
onSectionClick | (sectionId: string) => void | No | - | Callback fired when a section title is clicked |
readOnly | boolean | No | false | If true, disables drag-and-drop and shows read-only UI |
Section Interface
Each section in the sections array must have:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier for the section (use UUID) |
title | string | Yes | Display title of the section |
content | string | Yes | HTML content of the section (used for word count) |
children | string[] | No | Array of child section IDs (builds hierarchy) |
level | number | No | Manual level override (auto-calculated if not provided) |
Usage Examples
Basic Usage
import { DocumentOutline } from '@/components/DocumentOutline';
function DocumentEditor({ documentId }: { documentId: string }) {
const [sections, setSections] = useState<Section[]>([
{
id: 'section-1',
title: 'Introduction',
content: '<p>This is the introduction...</p>',
},
{
id: 'section-2',
title: 'Methodology',
content: '<p>This describes the methodology...</p>',
},
]);
const handleReorder = (newSections: Section[]) => {
setSections(newSections);
// Save to database
fetch(`/api/documents/${documentId}`, {
method: 'PUT',
body: JSON.stringify({ sections: newSections }),
});
};
const handleSectionClick = (sectionId: string) => {
// Scroll to section in document
scrollToSection(sectionId);
};
return (
<div className="flex gap-4">
<div className="w-64">
<DocumentOutline
sections={sections}
onReorder={handleReorder}
onSectionClick={handleSectionClick}
/>
</div>
<div className="flex-1">
{/* Main document content */}
</div>
</div>
);
}Read-Only Mode
<DocumentOutline
sections={sections}
onReorder={() => {}} // No-op in read-only mode
onSectionClick={handleSectionClick}
readOnly={true}
/>With Section Expansion Control
function ExpandedDocumentOutline({ sections }: { sections: Section[] }) {
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(sections.map((s) => s.id))
);
const handleToggle = (sectionId: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(sectionId)) {
next.delete(sectionId);
} else {
next.add(sectionId);
}
return next;
});
};
return (
<div className="flex gap-4">
<div className="w-64">
<DocumentOutline
sections={sections}
onReorder={handleReorder}
onSectionClick={handleSectionClick}
/>
</div>
<div className="flex-1">
{sections.map((section) => (
<section key={section.id} id={section.id}>
<h2>{section.title}</h2>
<div dangerouslySetInnerHTML={{ __html: section.content }} />
</section>
))}
</div>
</div>
);
}Generating Sections from Markdown Headers
import { marked } from 'marked';
function generateSectionsFromMarkdown(markdown: string): Section[] {
const parser = marked.parser(markdown);
const sections: Section[] = [];
let currentSection: Section | null = null;
let currentContent = '';
parser.forEach((block) => {
if (block.type === 'heading') {
// Save previous section
if (currentSection && currentContent.trim()) {
currentSection.content = currentContent.trim();
sections.push(currentSection);
}
// Start new section
currentSection = {
id: `section-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
title: block.text,
content: '',
};
currentContent = '';
} else if (currentSection) {
currentContent += `<p>${block.text}</p>`;
}
});
// Add last section
if (currentSection && currentContent.trim()) {
currentSection.content = currentContent.trim();
sections.push(currentSection);
}
return sections;
}State Management
Section Reordering
The component uses @dnd-kit for drag-and-drop functionality. When a section is dropped:
onDragStart- Stores the active section IDonDragEnd- Calculates new order usingarrayMoveonReorder- Callback with new section array- Parent component updates state and persists to database
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const activeId = active.id as string;
const overId = over.id as string;
// Find indices
const activeIndex = sections.findIndex((s) => s.id === activeId);
const overIndex = sections.findIndex((s) => s.id === overId);
if (activeIndex === -1 || overIndex === -1) return;
// Reorder sections
const newSections = arrayMove(sections, activeIndex, overIndex);
onReorder(newSections);
},
[sections, onReorder]
);Section Tree Building
The component automatically builds a hierarchical tree from flat section array:
function buildSectionTree(sections: Section[]): {
rootSections: Section[];
sectionMap: Map<string, Section>;
} {
const sectionMap = new Map<string, Section>();
const rootSections: Section[] = [];
// Create map for quick lookup
sections.forEach((section) => {
sectionMap.set(section.id, { ...section, level: 0 });
});
// Build tree structure
sections.forEach((section) => {
if (section.children && section.children.length > 0) {
section.children.forEach((childId) => {
const child = sectionMap.get(childId);
if (child) {
sectionMap.set(childId, { ...child, level: (section.level || 0) + 1 });
}
});
}
});
// Find root sections (sections that are not children of any other section)
const childIds = new Set<string>();
sections.forEach((section) => {
section.children?.forEach((childId) => childIds.add(childId));
});
sections.forEach((section) => {
if (!childIds.has(section.id)) {
rootSections.push(section);
}
});
return { rootSections, sectionMap };
}Word Count Calculation
Word count is calculated by stripping HTML tags and counting whitespace-separated words:
function countWords(content: string): number {
if (!content) return 0;
// Strip HTML tags
const text = content.replace(/<[^>]*>/g, ' ');
return text.split(/\s+/).filter(Boolean).length;
}Styling and Appearance
CSS Classes
The component uses semantic surface classes:
/* Container */
bg-surface-elevated border border-surface-elevated-border rounded-lg
/* Header */
bg-surface-base border-b border-surface-elevated-border
/* Section items */
hover:bg-surface-base-hover rounded-lg
text-surface-elevated-text
/* Active/dragging states */
opacity-50 (dragging)
transition-all
/* Indentation */
depth > 0 ? 'ml-6'
padding-left: depth * 16 + 12pxResponsive Layout
The component is designed to be placed in a sidebar:
<div className="flex gap-4">
{/* Left sidebar - Document Outline */}
<div className="w-64 flex-shrink-0">
<DocumentOutline sections={sections} onReorder={handleReorder} />
</div>
{/* Right - Main content */}
<div className="flex-1 min-w-0">
{/* Document editor or viewer */}
</div>
</div>Features Reference
Drag-and-Drop Reordering
Enabled when: readOnly={false} (default)
Behavior:
- Drag handle appears on hover (grip icon)
- Drag distance threshold: 8px (prevents accidental drags)
- Visual feedback: opacity 0.5, drag overlay shows section title
- Smooth animations during drag
Disabling: Set readOnly={true} to disable all drag functionality
Expand/Collapse
Automatic: All sections expanded by default
Toggle: Click chevron icon next to sections with children
Controls:
- Expand All - Show all subsections
- Collapse All - Hide all subsections
Word Count
Per section: Shows word count next to each section title Total: Shows cumulative word count at bottom Calculation: Strips HTML, counts whitespace-separated tokens
Navigation
Click to navigate: Click section title to scroll to that section
Callback: onSectionClick(sectionId) is fired on click
Use case: Highlight active section, scroll into view, update URL hash
Tree Structure
Parent-child relationships: Defined via children array
Automatic level: Calculated from hierarchy
Visual indentation: 16px per level + 12px base
Border indicator: Left border connects parent to children
Event Handlers
onReorder
onReorder: (sections: Section[]) => voidFired when: A section is dropped at a new position
Usage:
const handleReorder = (newSections: Section[]) => {
// Update local state
setSections(newSections);
// Persist to database
api.saveDocumentSections(documentId, newSections);
// Trigger analytics event
analytics.track('document_section_reordered', {
documentId,
newOrder: newSections.map((s) => s.id),
});
};onSectionClick
onSectionClick?: (sectionId: string) => voidFired when: A section title is clicked
Usage:
const handleSectionClick = (sectionId: string) => {
// Scroll to section
const element = document.getElementById(sectionId);
element?.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Update active section state
setActiveSection(sectionId);
// Update URL hash
window.history.pushState({}, '', `#${sectionId}`);
};Common Patterns
Syncing with Main Document
When sections are reordered in the outline, sync with the main document:
function DocumentEditor() {
const [sections, setSections] = useState<Section[]>([]);
const [documentContent, setDocumentContent] = useState('');
const handleReorder = async (newSections: Section[]) => {
setSections(newSections);
// Rebuild document content in new order
const orderedSections = newSections.map((section) => {
const sectionElement = document.getElementById(section.id);
return sectionElement?.outerHTML || '';
}).join('\n\n');
setDocumentContent(orderedSections);
// Save both sections order and content
await saveDocument();
};
}Generating Sections from Headers
Automatically create sections from markdown headers in the editor:
function SyncedDocumentEditor() {
const [sections, setSections] = useState<Section[]>([]);
const [markdown, setMarkdown] = useState('');
useEffect(() => {
// Parse markdown and extract headers as sections
const headers = extractHeaders(markdown);
setSections(headers);
}, [markdown]);
const extractHeaders = (md: string) => {
const lines = md.split('\n');
const sections: Section[] = [];
let currentSection: Section | null = null;
let currentContent = '';
lines.forEach((line) => {
const match = line.match(/^(#{1,3})\s+(.+)$/);
if (match) {
if (currentSection && currentContent.trim()) {
currentSection.content = currentContent.trim();
sections.push(currentSection);
}
currentSection = {
id: `section-${Date.now()}-${Math.random()}`,
title: match[2],
content: '',
};
currentContent = '';
} else if (currentSection) {
currentContent += line + '\n';
}
});
if (currentSection && currentContent.trim()) {
currentSection.content = currentContent.trim();
sections.push(currentSection);
}
return sections;
};
return (
<div className="flex gap-4">
<DocumentOutline sections={sections} onReorder={handleReorder} />
<Editor value={markdown} onChange={setMarkdown} />
</div>
);
}Highlighting Active Section
Track which section is currently visible:
function HighlightingDocumentOutline() {
const [sections, setSections] = useState<Section[]>([]);
const [activeSection, setActiveSection] = useState<string | null>(null);
useEffect(() => {
const handleScroll = () => {
// Find currently visible section
const visible = sections.find((section) => {
const element = document.getElementById(section.id);
if (!element) return false;
const rect = element.getBoundingClientRect();
return rect.top >= 0 && rect.top <= window.innerHeight / 2;
});
if (visible) {
setActiveSection(visible.id);
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [sections]);
return (
<DocumentOutline
sections={sections}
onReorder={handleReorder}
onSectionClick={setActiveSection}
/>
);
}Troubleshooting
Drag-and-Drop Not Working
Problem: Sections cannot be reordered via drag-and-drop
Solutions:
- Verify
readOnly={false}(or omit the prop) - Check that
@dnd-kit/coreand@dnd-kit/sortableare installed - Ensure
onReordercallback is provided - Check for JavaScript errors in console
Sections Not Hierarchical
Problem: All sections appear at same level
Solutions:
- Verify
childrenarrays are correctly populated - Check that child IDs match section IDs exactly
- Verify
buildSectionTreefunction is working (check logs) - Ensure section IDs are unique
Word Count Shows 0
Problem: All sections display “0” words
Solutions:
- Check that
contentprop contains HTML - Verify content is not empty string
- Check
countWordsfunction is being called - Inspect HTML to ensure tags are being stripped correctly
Drag Overlay Not Showing
Problem: No visual feedback when dragging sections
Solutions:
- Verify
DragOverlaycomponent is rendered - Check
activeSectionstate is being updated - Ensure CSS transitions are not conflicting
- Check that sensor is properly configured (distance threshold)
Expand/Collapse Not Working
Problem: Sections cannot be expanded or collapsed
Solutions:
- Verify sections have
childrenarray defined - Check
expandedSectionsstate is being managed - Ensure
handleTogglecallback is provided - Verify chevron icon is visible (section must have children)
Outline Doesn’t Sync with Document
Problem: Reordering outline doesn’t update document order
Solutions:
- Ensure
handleReorderupdates both outline state AND document content - Check that document rendering respects section order
- Verify
keyprops are using section IDs (not array indices) - Inspect document content after reorder to confirm changes
Performance Issues with Many Sections
Problem: Outline becomes slow with 50+ sections
Solutions:
- Limit visible sections (virtualize if needed)
- Debounce reorder operations
- Memoize tree building with
useMemo - Consider lazy loading subsections
- Reduce animation duration
Related Documentation
- Rich Text Editor - Content editing component
- Component Blocks Reference - Layout components
- Entity Management Guide - Using outline in entities
- Theme Customization - Styling guide
Next Steps
- Add keyboard navigation for accessibility
- Implement section search/filtering
- Add collapsible group functionality
- Create section templates
- Support nested drag-and-drop