Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

All notable changes to this project will be documented in this file.

## [Unreleased]

### Added
- Collapsible/expandable sections in table of contents sidebar using HTML5 `<details>` elements
- Native browser-based collapsibility with built-in keyboard navigation and screen reader support
- Smart hierarchical display: headings with sub-headings are collapsible, leaf headings remain simple links
- Recursive tree-building algorithm for proper nesting of TOC sections

### Fixed
- Click event conflicts between TOC links and collapsible sections (chevron now collapses, text navigates)
- Layout issue where sidebar would overlap content area when toggling sections
- Active state highlighting now consistent across all TOC heading levels (1-6)

### Changed
- Simplified TOC styling to use consistent blue color for all heading levels (removed grey color for deeper levels)
- Improved CSS specificity for active states to ensure proper visual feedback
- Updated documentation to reflect collapsible TOC feature and implementation details
- Code size increased from ~677 to ~748 lines (+71 lines for collapsibility feature)

## [1.0.4] - 2025-11-07

### Changed
Expand Down
2 changes: 1 addition & 1 deletion dist/extension.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/extension.js.map

Large diffs are not rendered by default.

25 changes: 24 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This document describes the design decisions and architectural patterns used in

## Overview

The extension provides a lightweight, secure markdown preview directly within VS Code. It prioritizes simplicity, security, and performance over feature breadth. The entire implementation is contained in a single extension file (~677 lines) with no external dependencies beyond the `marked` markdown parser.
The extension provides a lightweight, secure markdown preview directly within VS Code. It prioritizes simplicity, security, and performance over feature breadth. The entire implementation is contained in a single extension file (~748 lines) with no external dependencies beyond the `marked` markdown parser.

## High-Level Architecture

Expand Down Expand Up @@ -77,6 +77,29 @@ The extension's state is managed entirely in memory and is reset every time the
- Workspace-root paths (`/assets/icon.png`)
- HTTPS URLs and data URIs (unchanged)

## Table of Contents (TOC) Sidebar

### Implementation
The TOC is auto-generated from markdown headings (H1-H6) and displayed in a fixed sidebar.

**Features:**
- **Collapsible sections:** Headings with sub-headings are wrapped in HTML5 `<details>` elements for native collapsibility
- **Smart expansion:** All sections open by default (`<details open>`) for immediate document overview
- **Active tracking:** IntersectionObserver API highlights the currently visible section
- **Auto-scroll:** Clicking a TOC link smoothly scrolls to the heading and updates the active state

### Collapsibility Logic
The `generateTOC()` function uses a recursive algorithm:
1. For each heading, look ahead to see if the next heading has a deeper level
2. If yes, wrap in `<details open><summary>link</summary><children></details>`
3. If no, render as a simple link (leaf node)
4. Recursively process children, tracking parent levels to properly close nested structures

This approach provides native browser collapsibility without JavaScript dependencies, ensuring:
- **Accessibility:** Built-in keyboard navigation and screen reader support
- **Performance:** No JavaScript overhead for expand/collapse operations
- **Simplicity:** ~50 lines of code, easy to understand and maintain

## Performance Characteristics

### Current Approach
Expand Down
59 changes: 59 additions & 0 deletions examples/test-collapsible.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Collapsible TOC Test

This document tests the collapsible table of contents feature.

## Section 1: Introduction

This is a simple section with no subsections.

## Section 2: Features

This section has subsections to test collapsibility.

### Feature 2.1: Easy to Use

Some content here.

### Feature 2.2: Fast Performance

#### Performance Metric 2.2.1

Details about metrics.

#### Performance Metric 2.2.2

More details.

### Feature 2.3: Lightweight

This feature has no subsections.

## Section 3: Installation

Simple section without children.

## Section 4: Advanced Topics

Another section with nested content.

### Topic 4.1: Configuration

#### Config File 4.1.1

How to configure.

##### Advanced Options 4.1.1.1

Deep nesting test.

###### Very Deep 4.1.1.1.1

Maximum depth test.

### Topic 4.2: Troubleshooting

Common issues and solutions.

## Section 5: Conclusion

Final thoughts.
7 changes: 4 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ A minimal VS Code extension for previewing Markdown with Mermaid diagrams and Ma

## Why This Extension?

- **Lightweight:** ~51 KB packaged (no massive dependencies)
- **Lightweight:** ~53 KB packaged (no massive dependencies)
- **Privacy-Friendly:** No tracking, no analytics, no data collection. Your markdown stays on your machine
- **Simple:** ~677 lines of code, easy to understand and maintain
- **Simple:** ~748 lines of code, easy to understand and maintain
- **Fast:** Live preview updates as you type
- **Secure:** Content Security Policy, nonce-based script execution
- **One Job:** Previews Markdown. That's it. No themes, no plugins, no bloat.
Expand All @@ -16,7 +16,8 @@ A minimal VS Code extension for previewing Markdown with Mermaid diagrams and Ma
## Features

- Real-time Markdown preview in a side panel
- Interactive table of contents sidebar for easy document navigation
- Interactive table of contents sidebar with collapsible sections for easy document navigation
- Collapsible/expandable TOC headings to manage large documents efficiently
- Auto-scrolling outline that highlights your current section as you read
- Click-to-scroll navigation in the TOC for quick jumping between sections
- All standard Markdown elements (headings, lists, tables, code blocks, images, etc.)
Expand Down
137 changes: 106 additions & 31 deletions src/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,45 +261,65 @@
}

/**
* Generates nested TOC HTML from headings array
* Generates nested TOC HTML from headings array with collapsible sections
*
* Uses HTML5 <details> elements for native collapsibility.
* Headings with children are wrapped in <details open> for expand/collapse behavior.
*
* @param {Array} headings - Array of heading objects with level, text, id
* @returns {string} HTML for the nested TOC list
*/
function generateTOC(headings) {
if (headings.length === 0) return "<p style=\"font-size: 0.9em; color: #888;\">No headings found</p>";

let tocHtml = "<ul class=\"toc-list\">";
let currentLevel = 0;
/**
* Recursively builds TOC tree structure with collapsible sections
* @param {number} startIndex - Index to start processing from
* @param {number} parentLevel - Level of the parent heading (0 for root)
* @returns {Object} Object with {html: string, nextIndex: number}
*/
function buildTree(startIndex, parentLevel) {
let html = '';

Check failure on line 282 in src/extension.js

View workflow job for this annotation

GitHub Actions / lint

Strings must use doublequote
let i = startIndex;

while (i < headings.length) {
const heading = headings[i];

// Stop if we've returned to parent's level or higher
if (heading.level <= parentLevel) {
break;
}

headings.forEach((heading) => {
// Close deeper levels
while (currentLevel >= heading.level) {
tocHtml += "</ul>";
currentLevel--;
}
// Check if next heading is a child (deeper level)
const hasChildren = i + 1 < headings.length && headings[i + 1].level > heading.level;

// Open new levels
while (currentLevel < heading.level - 1) {
tocHtml += "<ul class=\"toc-list\">";
currentLevel++;
}
if (hasChildren) {
// Heading with children: wrap in <details> for collapsibility
html += `<li class="toc-item toc-level-${heading.level}">`;
html += `<details open>`;

Check failure on line 299 in src/extension.js

View workflow job for this annotation

GitHub Actions / lint

Strings must use doublequote
html += `<summary><a href="#${heading.id}" class="toc-link">${heading.text}</a></summary>`;
html += `<ul class="toc-list">`;

Check failure on line 301 in src/extension.js

View workflow job for this annotation

GitHub Actions / lint

Strings must use doublequote

if (currentLevel < heading.level) {
tocHtml += "<ul class=\"toc-list\">";
currentLevel++;
}
// Recursively process children
const result = buildTree(i + 1, heading.level);
html += result.html;
i = result.nextIndex;

tocHtml += `<li class="toc-item toc-level-${heading.level}"><a href="#${heading.id}" class="toc-link">${heading.text}</a></li>`;
});
html += `</ul></details></li>`;

Check failure on line 308 in src/extension.js

View workflow job for this annotation

GitHub Actions / lint

Strings must use doublequote
} else {
// Leaf heading: simple link without collapsibility
html += `<li class="toc-item toc-level-${heading.level}">`;
html += `<a href="#${heading.id}" class="toc-link">${heading.text}</a>`;
html += `</li>`;

Check failure on line 313 in src/extension.js

View workflow job for this annotation

GitHub Actions / lint

Strings must use doublequote
i++;
}
}

// Close all open levels
while (currentLevel > 0) {
tocHtml += "</ul>";
currentLevel--;
return { html, nextIndex: i };
}

return tocHtml;
const result = buildTree(0, 0);
return `<ul class="toc-list">${result.html}</ul>`;
}

/**
Expand Down Expand Up @@ -353,7 +373,6 @@
line-height: 1.6;
margin: 0;
padding: 0;
display: flex;
background: #fff;
}

Expand Down Expand Up @@ -432,21 +451,65 @@
.toc-level-5 .toc-link,
.toc-level-6 .toc-link {
font-size: 0.85em;
}

/* Collapsible TOC sections using HTML5 details */
details {
margin: 0;
}

summary {
list-style: none;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
position: relative;
}

/* Hide default disclosure triangle */
summary::-webkit-details-marker {
display: none;
}

/* Custom chevron indicator */
summary::before {
content: '▶';
display: inline-block;
width: 16px;
height: 16px;
margin-right: 4px;
font-size: 0.7em;
color: #666;
transition: transform 0.2s ease;
flex-shrink: 0;
}

.toc-level-4 .toc-link:hover,
.toc-level-5 .toc-link:hover,
.toc-level-6 .toc-link:hover {
background: #f0f0f0;
details[open] > summary::before {
transform: rotate(90deg);
}

summary:hover::before {
color: #0066cc;
}

/* Make the link inside summary take full width */
summary .toc-link {
flex: 1;
margin-left: -4px;
}

/* Nested lists inside details */
details > .toc-list {
margin-top: 2px;
}

.content {
margin-left: 280px;
flex: 1;
padding: 20px;
max-width: 900px;
min-height: 100vh;
width: auto;
}

pre {
Expand Down Expand Up @@ -582,6 +645,7 @@
tocLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation(); // Prevent summary from toggling when clicking the link
const id = link.getAttribute('href').substring(1);
const target = document.getElementById(id);
if (target) {
Expand All @@ -591,6 +655,17 @@
});
});

// Prevent details toggle from affecting link navigation
// Click only the chevron to collapse/expand, click the text to navigate
document.querySelectorAll('summary').forEach(summary => {
summary.addEventListener('click', (e) => {
// If clicking on the link inside summary, don't toggle details
if (e.target.classList.contains('toc-link') || e.target.closest('.toc-link')) {
e.preventDefault(); // Prevent details toggle
}
});
});

// Update active TOC link based on scroll position and scroll TOC to show it
function updateActiveTOC(activeId) {
const sidebar = document.querySelector('.toc-sidebar');
Expand Down
Loading