Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(docs): minimap outline for long documents (#60)

Add VS Code-style minimap that shows document headings as a navigable
outline. The minimap appears on the right side when a document has 2+
headings, with a viewport indicator showing current scroll position.

- Add minimap.ts: extractHeadings from HTML, computeViewportIndicator
- Click heading in minimap to smooth-scroll to that section
- Indented display by heading level (H1 bold, H2/H3 indented)
- Auto-hides for short documents with fewer than 2 headings
- Viewport indicator tracks scroll position

+229
+46
src/css/app.css
··· 3112 3112 border-radius: 2px; 3113 3113 } 3114 3114 3115 + /* Minimap */ 3116 + .minimap { 3117 + position: fixed; 3118 + right: 0; 3119 + top: 60px; 3120 + bottom: 0; 3121 + width: 140px; 3122 + background: var(--color-surface); 3123 + border-left: 1px solid var(--color-border); 3124 + overflow: hidden; 3125 + z-index: 10; 3126 + font-size: 0.65rem; 3127 + line-height: 1.3; 3128 + } 3129 + 3130 + .minimap-viewport { 3131 + position: absolute; 3132 + right: 0; 3133 + left: 0; 3134 + background: oklch(0.65 0.1 250 / 0.1); 3135 + border: 1px solid oklch(0.65 0.1 250 / 0.3); 3136 + border-radius: 2px; 3137 + pointer-events: none; 3138 + transition: top 0.1s ease, height 0.1s ease; 3139 + } 3140 + 3141 + .minimap-headings { 3142 + padding: 8px 10px; 3143 + cursor: pointer; 3144 + } 3145 + 3146 + .minimap-heading { 3147 + padding: 2px 0; 3148 + color: var(--color-text-muted); 3149 + white-space: nowrap; 3150 + overflow: hidden; 3151 + text-overflow: ellipsis; 3152 + transition: color 0.15s; 3153 + } 3154 + .minimap-heading:hover { 3155 + color: var(--color-text); 3156 + } 3157 + .minimap-heading[data-level="1"] { font-weight: 600; color: var(--color-text); } 3158 + .minimap-heading[data-level="2"] { padding-left: 8px; } 3159 + .minimap-heading[data-level="3"] { padding-left: 16px; font-size: 0.6rem; } 3160 + 3115 3161 .footnote-section { 3116 3162 border-top: 1px solid var(--color-border); 3117 3163 margin-top: 2rem;
+4
src/docs/index.html
··· 373 373 374 374 <div class="editor-container"> 375 375 <div class="editor-wrapper" id="editor"></div> 376 + <div class="minimap" id="minimap" style="display:none"> 377 + <div class="minimap-viewport" id="minimap-viewport"></div> 378 + <div class="minimap-headings" id="minimap-headings"></div> 379 + </div> 376 380 <textarea class="markdown-source-textarea" id="markdown-source" style="display:none" spellcheck="false" aria-label="Markdown source editor"></textarea> 377 381 <div class="footnote-section" id="footnote-section" style="display:none"></div> 378 382 </div>
+51
src/docs/main.ts
··· 51 51 import { createMarkdownToggle, TOGGLE_MODE } from './markdown-toggle.js'; 52 52 import { VersionManager, computeWordCount } from '../lib/version-history.js'; 53 53 import { createVersionPanel } from '../version-panel.js'; 54 + import { extractHeadings, computeViewportIndicator } from './minimap.js'; 54 55 import { SuggestionManager, createSuggestionAttrs } from '../lib/suggesting.js'; 55 56 import { OfflineManager } from '../lib/offline.js'; 56 57 import { extractHeadings, OutlineState } from './outline.js'; ··· 1218 1219 } 1219 1220 editor.on('update', updateFootnoteSection); 1220 1221 updateFootnoteSection(); 1222 + 1223 + // --- Minimap (#60) --- 1224 + const minimapEl = document.getElementById('minimap') as HTMLElement; 1225 + const minimapViewport = document.getElementById('minimap-viewport') as HTMLElement; 1226 + const minimapHeadingsEl = document.getElementById('minimap-headings') as HTMLElement; 1227 + 1228 + function updateMinimap() { 1229 + const html = editor.getHTML(); 1230 + const headings = extractHeadings(html); 1231 + 1232 + if (headings.length < 2) { 1233 + minimapEl.style.display = 'none'; 1234 + return; 1235 + } 1236 + 1237 + minimapEl.style.display = ''; 1238 + minimapHeadingsEl.innerHTML = headings.map(h => 1239 + `<div class="minimap-heading" data-level="${h.level}" title="${h.text}">${h.text}</div>` 1240 + ).join(''); 1241 + 1242 + // Click to scroll to heading 1243 + minimapHeadingsEl.querySelectorAll('.minimap-heading').forEach((el, i) => { 1244 + el.addEventListener('click', () => { 1245 + const headingEls = editor.view.dom.querySelectorAll('h1, h2, h3, h4, h5, h6'); 1246 + if (headingEls[i]) { 1247 + headingEls[i].scrollIntoView({ behavior: 'smooth', block: 'start' }); 1248 + } 1249 + }); 1250 + }); 1251 + 1252 + updateMinimapViewport(); 1253 + } 1254 + 1255 + function updateMinimapViewport() { 1256 + const editorEl = editor.view.dom.closest('.editor-container') || editor.view.dom.parentElement; 1257 + if (!editorEl) return; 1258 + const scrollEl = editorEl.closest('.editor-container') || document.documentElement; 1259 + const { top, height } = computeViewportIndicator( 1260 + scrollEl.scrollTop, 1261 + scrollEl.clientHeight, 1262 + scrollEl.scrollHeight, 1263 + ); 1264 + minimapViewport.style.top = top + '%'; 1265 + minimapViewport.style.height = height + '%'; 1266 + } 1267 + 1268 + editor.on('update', updateMinimap); 1269 + document.addEventListener('scroll', updateMinimapViewport, { passive: true }); 1270 + // Delayed initial render 1271 + setTimeout(updateMinimap, 500); 1221 1272 1222 1273 // --- Keyboard Shortcut Cheatsheet Modal (#15) --- 1223 1274 const DOCS_SHORTCUTS = [
+64
src/docs/minimap.ts
··· 1 + /** 2 + * Document Minimap — VS Code-style overview for long documents. 3 + * 4 + * Pure logic module: heading extraction, viewport calculation. 5 + * DOM rendering is handled in main.ts. 6 + */ 7 + 8 + export interface MinimapHeading { 9 + level: number; 10 + text: string; 11 + id: string; 12 + } 13 + 14 + export interface ViewportIndicator { 15 + top: number; // percentage 16 + height: number; // percentage 17 + } 18 + 19 + /** 20 + * Extract headings from HTML content for the minimap outline. 21 + */ 22 + export function extractHeadings(html: string): MinimapHeading[] { 23 + if (!html) return []; 24 + 25 + const headings: MinimapHeading[] = []; 26 + // Match h1-h6 tags with optional id attribute 27 + const regex = /<h([1-6])(?:\s[^>]*?(?:id="([^"]*)")?[^>]*)?>([\s\S]*?)<\/h\1>/gi; 28 + let match; 29 + 30 + while ((match = regex.exec(html)) !== null) { 31 + const level = parseInt(match[1], 10); 32 + const id = match[2] || ''; 33 + // Strip HTML tags from heading text 34 + const text = match[3].replace(/<[^>]+>/g, '').trim(); 35 + if (text) { 36 + headings.push({ level, text, id }); 37 + } 38 + } 39 + 40 + return headings; 41 + } 42 + 43 + /** 44 + * Compute the viewport indicator position and size as percentages. 45 + */ 46 + export function computeViewportIndicator( 47 + scrollTop: number, 48 + viewportHeight: number, 49 + totalHeight: number, 50 + ): ViewportIndicator { 51 + if (totalHeight <= 0) { 52 + return { top: 0, height: 100 }; 53 + } 54 + 55 + const top = Math.max(0, (scrollTop / totalHeight) * 100); 56 + let height = Math.max(5, (viewportHeight / totalHeight) * 100); 57 + 58 + // Clamp so it doesn't overflow 59 + if (top + height > 100) { 60 + height = 100 - top; 61 + } 62 + 63 + return { top, height }; 64 + }
+64
tests/minimap.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + extractHeadings, 4 + computeViewportIndicator, 5 + type MinimapHeading, 6 + } from '../src/docs/minimap.js'; 7 + 8 + describe('Document Minimap', () => { 9 + describe('extractHeadings', () => { 10 + it('extracts headings from HTML', () => { 11 + const html = '<h1>Title</h1><p>Some text</p><h2>Section A</h2><p>More text</p><h3>Subsection</h3>'; 12 + const headings = extractHeadings(html); 13 + expect(headings).toHaveLength(3); 14 + expect(headings[0]).toEqual({ level: 1, text: 'Title', id: '' }); 15 + expect(headings[1]).toEqual({ level: 2, text: 'Section A', id: '' }); 16 + expect(headings[2]).toEqual({ level: 3, text: 'Subsection', id: '' }); 17 + }); 18 + 19 + it('returns empty array for no headings', () => { 20 + expect(extractHeadings('<p>Just text</p>')).toEqual([]); 21 + }); 22 + 23 + it('handles empty HTML', () => { 24 + expect(extractHeadings('')).toEqual([]); 25 + }); 26 + 27 + it('strips HTML tags from heading text', () => { 28 + const html = '<h1><strong>Bold</strong> Title</h1>'; 29 + const headings = extractHeadings(html); 30 + expect(headings[0].text).toBe('Bold Title'); 31 + }); 32 + 33 + it('extracts heading ids if present', () => { 34 + const html = '<h1 id="intro">Introduction</h1><h2 id="ch1">Chapter 1</h2>'; 35 + const headings = extractHeadings(html); 36 + expect(headings[0].id).toBe('intro'); 37 + expect(headings[1].id).toBe('ch1'); 38 + }); 39 + }); 40 + 41 + describe('computeViewportIndicator', () => { 42 + it('computes viewport position as percentage', () => { 43 + const result = computeViewportIndicator(100, 500, 2000); 44 + expect(result.top).toBe(5); // 100/2000 * 100 45 + expect(result.height).toBe(25); // 500/2000 * 100 46 + }); 47 + 48 + it('clamps to 100% max', () => { 49 + const result = computeViewportIndicator(1800, 500, 2000); 50 + expect(result.top + result.height).toBeLessThanOrEqual(100); 51 + }); 52 + 53 + it('handles zero total height', () => { 54 + const result = computeViewportIndicator(0, 500, 0); 55 + expect(result.top).toBe(0); 56 + expect(result.height).toBe(100); 57 + }); 58 + 59 + it('minimum height is 5%', () => { 60 + const result = computeViewportIndicator(0, 10, 10000); 61 + expect(result.height).toBeGreaterThanOrEqual(5); 62 + }); 63 + }); 64 + });