DeveloperDocument Outline

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

PropTypeRequiredDefaultDescription
sectionsSection[]Yes-Array of section objects with id, title, content, and optional children
onReorder(sections: Section[]) => voidYes-Callback fired when sections are reordered via drag-and-drop
onSectionClick(sectionId: string) => voidNo-Callback fired when a section title is clicked
readOnlybooleanNofalseIf true, disables drag-and-drop and shows read-only UI

Section Interface

Each section in the sections array must have:

FieldTypeRequiredDescription
idstringYesUnique identifier for the section (use UUID)
titlestringYesDisplay title of the section
contentstringYesHTML content of the section (used for word count)
childrenstring[]NoArray of child section IDs (builds hierarchy)
levelnumberNoManual 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:

  1. onDragStart - Stores the active section ID
  2. onDragEnd - Calculates new order using arrayMove
  3. onReorder - Callback with new section array
  4. 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 + 12px

Responsive 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

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[]) => void

Fired 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) => void

Fired 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:

  1. Verify readOnly={false} (or omit the prop)
  2. Check that @dnd-kit/core and @dnd-kit/sortable are installed
  3. Ensure onReorder callback is provided
  4. Check for JavaScript errors in console

Sections Not Hierarchical

Problem: All sections appear at same level

Solutions:

  1. Verify children arrays are correctly populated
  2. Check that child IDs match section IDs exactly
  3. Verify buildSectionTree function is working (check logs)
  4. Ensure section IDs are unique

Word Count Shows 0

Problem: All sections display “0” words

Solutions:

  1. Check that content prop contains HTML
  2. Verify content is not empty string
  3. Check countWords function is being called
  4. Inspect HTML to ensure tags are being stripped correctly

Drag Overlay Not Showing

Problem: No visual feedback when dragging sections

Solutions:

  1. Verify DragOverlay component is rendered
  2. Check activeSection state is being updated
  3. Ensure CSS transitions are not conflicting
  4. Check that sensor is properly configured (distance threshold)

Expand/Collapse Not Working

Problem: Sections cannot be expanded or collapsed

Solutions:

  1. Verify sections have children array defined
  2. Check expandedSections state is being managed
  3. Ensure handleToggle callback is provided
  4. Verify chevron icon is visible (section must have children)

Outline Doesn’t Sync with Document

Problem: Reordering outline doesn’t update document order

Solutions:

  1. Ensure handleReorder updates both outline state AND document content
  2. Check that document rendering respects section order
  3. Verify key props are using section IDs (not array indices)
  4. Inspect document content after reorder to confirm changes

Performance Issues with Many Sections

Problem: Outline becomes slow with 50+ sections

Solutions:

  1. Limit visible sections (virtualize if needed)
  2. Debounce reorder operations
  3. Memoize tree building with useMemo
  4. Consider lazy loading subsections
  5. Reduce animation duration

Next Steps

  • Add keyboard navigation for accessibility
  • Implement section search/filtering
  • Add collapsible group functionality
  • Create section templates
  • Support nested drag-and-drop