From fc5e16f1f243c1d9cb091993b9e2de76b2af1576 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Wed, 10 Jun 2026 19:24:45 +0530 Subject: [PATCH] fix: resolve Table of Contents smooth scroll navigation (#169) --- desktop-app/resources/js/preview-worker.js | 14 ++ desktop-app/resources/js/script.js | 90 ++++++++++- preview-worker.js | 14 ++ script.js | 67 +++++++- test_toc_scrolling.spec.js | 169 +++++++++++++++++++++ 5 files changed, 347 insertions(+), 7 deletions(-) create mode 100644 test_toc_scrolling.spec.js diff --git a/desktop-app/resources/js/preview-worker.js b/desktop-app/resources/js/preview-worker.js index 4a97c054..97c9a9a2 100644 --- a/desktop-app/resources/js/preview-worker.js +++ b/desktop-app/resources/js/preview-worker.js @@ -319,6 +319,20 @@ function configureMarked() { return `
${highlightedCode}
`; }; + renderer.heading = function(text, level, raw) { + let id = raw + .toLowerCase() + .trim() + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, '-') + .replace(/[^\w-]/g, '') + .replace(/-+/g, '-'); + if (!id) { + id = `heading-worker-${Math.random().toString(36).substr(2, 9)}`; + } + return `${text}`; + }; + marked.use({ extensions: [ blockMathExtension, diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index b19f2af0..bdb83b4b 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -61,6 +61,7 @@ document.addEventListener("DOMContentLoaded", function () { let syncScrollingEnabled = true; let isEditorScrolling = false; let isPreviewScrolling = false; + let isProgrammaticScrolling = false; let scrollSyncTimeout = null; const SCROLL_SYNC_DELAY = 10; @@ -931,6 +932,20 @@ document.addEventListener("DOMContentLoaded", function () { return `
${highlightedCode}
`; }; + renderer.heading = function (text, level, raw) { + let id = raw + .toLowerCase() + .trim() + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, '-') + .replace(/[^\w-]/g, '') + .replace(/-+/g, '-'); + if (!id) { + id = 'heading-' + Math.random().toString(36).substr(2, 9); + } + return `${text}`; + }; + marked.use({ extensions: [ blockMathExtension, @@ -2921,7 +2936,7 @@ document.addEventListener("DOMContentLoaded", function () { } function syncEditorToPreview() { - if (!syncScrollingEnabled || isPreviewScrolling) return; + if (!syncScrollingEnabled || isPreviewScrolling || isProgrammaticScrolling) return; isEditorScrolling = true; if (scrollSyncTimeout) cancelAnimationFrame(scrollSyncTimeout); @@ -2944,7 +2959,7 @@ document.addEventListener("DOMContentLoaded", function () { } function syncPreviewToEditor() { - if (!syncScrollingEnabled || isEditorScrolling) return; + if (!syncScrollingEnabled || isEditorScrolling || isProgrammaticScrolling) return; isPreviewScrolling = true; if (scrollSyncTimeout) cancelAnimationFrame(scrollSyncTimeout); @@ -6583,12 +6598,33 @@ document.addEventListener("DOMContentLoaded", function () { .markdown-body { box-sizing: border-box; min-width: 200px; - max-width: 980px; + max-width: 100%; + width: fit-content; margin: 0 auto; padding: 45px; background-color: ${isDarkTheme ? "#0d1117" : "#ffffff"}; color: ${isDarkTheme ? "#c9d1d9" : "#24292e"}; } + .markdown-body > p, + .markdown-body > ul, + .markdown-body > ol, + .markdown-body > blockquote, + .markdown-body > h1, + .markdown-body > h2, + .markdown-body > h3, + .markdown-body > h4, + .markdown-body > h5, + .markdown-body > h6, + .markdown-body > pre, + .markdown-body > table, + .markdown-body > details, + .markdown-body > dl, + .markdown-body > hr { + max-width: 980px; + margin-left: auto !important; + margin-right: auto !important; + } + /* Syntax Highlighting */ .hljs-doctag, .hljs-keyword, .hljs-template-tag, .hljs-template-variable, .hljs-type, .hljs-variable.language_ { color: ${isDarkTheme ? "#ff7b72" : "#d73a49"}; } @@ -9646,7 +9682,53 @@ document.addEventListener("DOMContentLoaded", function () { const href = link.getAttribute('href'); if (href) { if (href.startsWith('#')) { - return; // Allow internal anchor navigation + const targetId = decodeURIComponent(href.slice(1)); + let targetEl = null; + if (targetId) { + try { + targetEl = markdownPreview.querySelector(`[id="${CSS.escape(targetId)}"]`) || + markdownPreview.querySelector(`[name="${CSS.escape(targetId)}"]`); + } catch (err) { + targetEl = Array.from(markdownPreview.querySelectorAll('[id], [name]')).find(el => { + return el.getAttribute('id') === targetId || el.getAttribute('name') === targetId; + }); + } + + if (!targetEl) { + const cleanTargetId = targetId.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (cleanTargetId) { + targetEl = Array.from(markdownPreview.querySelectorAll('h1, h2, h3, h4, h5, h6')).find(heading => { + const cleanText = heading.textContent.toLowerCase().replace(/[^a-z0-9]/g, ''); + return cleanText === cleanTargetId; + }); + } + } + } + if (targetEl) { + e.preventDefault(); + isProgrammaticScrolling = true; + + // Scroll preview pane to target heading + targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + // Scroll editor pane to the matching synced position + const previewScrollRange = previewPane.scrollHeight - previewPane.clientHeight; + const targetRatio = previewScrollRange > 0 ? Math.min(1, Math.max(0, targetEl.offsetTop / previewScrollRange)) : 0; + const editorScrollPosition = targetRatio * (markdownEditor.scrollHeight - markdownEditor.clientHeight); + + markdownEditor.scrollTo({ + top: editorScrollPosition, + behavior: 'smooth' + }); + + if (window.programmaticScrollTimeout) { + clearTimeout(window.programmaticScrollTimeout); + } + window.programmaticScrollTimeout = setTimeout(() => { + isProgrammaticScrolling = false; + }, 1000); + } + return; } e.preventDefault(); diff --git a/preview-worker.js b/preview-worker.js index 4a97c054..97c9a9a2 100644 --- a/preview-worker.js +++ b/preview-worker.js @@ -319,6 +319,20 @@ function configureMarked() { return `
${highlightedCode}
`; }; + renderer.heading = function(text, level, raw) { + let id = raw + .toLowerCase() + .trim() + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, '-') + .replace(/[^\w-]/g, '') + .replace(/-+/g, '-'); + if (!id) { + id = `heading-worker-${Math.random().toString(36).substr(2, 9)}`; + } + return `${text}`; + }; + marked.use({ extensions: [ blockMathExtension, diff --git a/script.js b/script.js index 88d8c07b..bdb83b4b 100644 --- a/script.js +++ b/script.js @@ -61,6 +61,7 @@ document.addEventListener("DOMContentLoaded", function () { let syncScrollingEnabled = true; let isEditorScrolling = false; let isPreviewScrolling = false; + let isProgrammaticScrolling = false; let scrollSyncTimeout = null; const SCROLL_SYNC_DELAY = 10; @@ -931,6 +932,20 @@ document.addEventListener("DOMContentLoaded", function () { return `
${highlightedCode}
`; }; + renderer.heading = function (text, level, raw) { + let id = raw + .toLowerCase() + .trim() + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, '-') + .replace(/[^\w-]/g, '') + .replace(/-+/g, '-'); + if (!id) { + id = 'heading-' + Math.random().toString(36).substr(2, 9); + } + return `${text}`; + }; + marked.use({ extensions: [ blockMathExtension, @@ -2921,7 +2936,7 @@ document.addEventListener("DOMContentLoaded", function () { } function syncEditorToPreview() { - if (!syncScrollingEnabled || isPreviewScrolling) return; + if (!syncScrollingEnabled || isPreviewScrolling || isProgrammaticScrolling) return; isEditorScrolling = true; if (scrollSyncTimeout) cancelAnimationFrame(scrollSyncTimeout); @@ -2944,7 +2959,7 @@ document.addEventListener("DOMContentLoaded", function () { } function syncPreviewToEditor() { - if (!syncScrollingEnabled || isEditorScrolling) return; + if (!syncScrollingEnabled || isEditorScrolling || isProgrammaticScrolling) return; isPreviewScrolling = true; if (scrollSyncTimeout) cancelAnimationFrame(scrollSyncTimeout); @@ -9667,7 +9682,53 @@ document.addEventListener("DOMContentLoaded", function () { const href = link.getAttribute('href'); if (href) { if (href.startsWith('#')) { - return; // Allow internal anchor navigation + const targetId = decodeURIComponent(href.slice(1)); + let targetEl = null; + if (targetId) { + try { + targetEl = markdownPreview.querySelector(`[id="${CSS.escape(targetId)}"]`) || + markdownPreview.querySelector(`[name="${CSS.escape(targetId)}"]`); + } catch (err) { + targetEl = Array.from(markdownPreview.querySelectorAll('[id], [name]')).find(el => { + return el.getAttribute('id') === targetId || el.getAttribute('name') === targetId; + }); + } + + if (!targetEl) { + const cleanTargetId = targetId.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (cleanTargetId) { + targetEl = Array.from(markdownPreview.querySelectorAll('h1, h2, h3, h4, h5, h6')).find(heading => { + const cleanText = heading.textContent.toLowerCase().replace(/[^a-z0-9]/g, ''); + return cleanText === cleanTargetId; + }); + } + } + } + if (targetEl) { + e.preventDefault(); + isProgrammaticScrolling = true; + + // Scroll preview pane to target heading + targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + // Scroll editor pane to the matching synced position + const previewScrollRange = previewPane.scrollHeight - previewPane.clientHeight; + const targetRatio = previewScrollRange > 0 ? Math.min(1, Math.max(0, targetEl.offsetTop / previewScrollRange)) : 0; + const editorScrollPosition = targetRatio * (markdownEditor.scrollHeight - markdownEditor.clientHeight); + + markdownEditor.scrollTo({ + top: editorScrollPosition, + behavior: 'smooth' + }); + + if (window.programmaticScrollTimeout) { + clearTimeout(window.programmaticScrollTimeout); + } + window.programmaticScrollTimeout = setTimeout(() => { + isProgrammaticScrolling = false; + }, 1000); + } + return; } e.preventDefault(); diff --git a/test_toc_scrolling.spec.js b/test_toc_scrolling.spec.js new file mode 100644 index 00000000..8619993f --- /dev/null +++ b/test_toc_scrolling.spec.js @@ -0,0 +1,169 @@ +const { test, expect } = require('@playwright/test'); +const path = require('path'); + +test('TOC Anchor Link Smooth Scrolling', async ({ page }) => { + test.setTimeout(60000); + + // Handle console logs from the page + page.on('console', msg => { + console.log(`BROWSER LOG: ${msg.text()}`); + }); + + page.on('pageerror', err => { + console.error(`BROWSER ERROR: ${err.message}`); + }); + + const indexPath = 'file:///' + path.resolve('index.html').replace(/\\/g, '/'); + console.log(`Navigating to ${indexPath}...`); + await page.goto(indexPath); + + // Wait for the editor to load + await page.waitForSelector('#markdown-editor'); + + // Load a long markdown document with a TOC and headings + const tocMarkdown = ` +# Table of Contents +- [Section 1](#section-1) +- [Section 2](#section-2) +- [Section 3](#section-3) + +# Section 1 +Paragraph content to take up vertical space 1. +Paragraph content to take up vertical space 2. +Paragraph content to take up vertical space 3. +Paragraph content to take up vertical space 4. +Paragraph content to take up vertical space 5. +Paragraph content to take up vertical space 6. +Paragraph content to take up vertical space 7. +Paragraph content to take up vertical space 8. +Paragraph content to take up vertical space 9. +Paragraph content to take up vertical space 10. +Paragraph content to take up vertical space 11. +Paragraph content to take up vertical space 12. +Paragraph content to take up vertical space 13. +Paragraph content to take up vertical space 14. +Paragraph content to take up vertical space 15. +Paragraph content to take up vertical space 16. +Paragraph content to take up vertical space 17. +Paragraph content to take up vertical space 18. +Paragraph content to take up vertical space 19. +Paragraph content to take up vertical space 20. +Paragraph content to take up vertical space 21. +Paragraph content to take up vertical space 22. +Paragraph content to take up vertical space 23. +Paragraph content to take up vertical space 24. +Paragraph content to take up vertical space 25. +Paragraph content to take up vertical space 26. +Paragraph content to take up vertical space 27. +Paragraph content to take up vertical space 28. +Paragraph content to take up vertical space 29. +Paragraph content to take up vertical space 30. + +# Section 2 +This is Section 2 content. +Paragraph content to take up vertical space 1. +Paragraph content to take up vertical space 2. +Paragraph content to take up vertical space 3. +Paragraph content to take up vertical space 4. +Paragraph content to take up vertical space 5. +Paragraph content to take up vertical space 6. +Paragraph content to take up vertical space 7. +Paragraph content to take up vertical space 8. +Paragraph content to take up vertical space 9. +Paragraph content to take up vertical space 10. +Paragraph content to take up vertical space 11. +Paragraph content to take up vertical space 12. +Paragraph content to take up vertical space 13. +Paragraph content to take up vertical space 14. +Paragraph content to take up vertical space 15. +Paragraph content to take up vertical space 16. +Paragraph content to take up vertical space 17. +Paragraph content to take up vertical space 18. +Paragraph content to take up vertical space 19. +Paragraph content to take up vertical space 20. +Paragraph content to take up vertical space 21. +Paragraph content to take up vertical space 22. +Paragraph content to take up vertical space 23. +Paragraph content to take up vertical space 24. +Paragraph content to take up vertical space 25. +Paragraph content to take up vertical space 26. +Paragraph content to take up vertical space 27. +Paragraph content to take up vertical space 28. +Paragraph content to take up vertical space 29. +Paragraph content to take up vertical space 30. + +# Section 3 +This is Section 3 content. +`; + + console.log("Setting editor value..."); + await page.evaluate((content) => { + const editor = document.getElementById('markdown-editor'); + editor.value = content; + editor.dispatchEvent(new Event('input', { bubbles: true })); + editor.dispatchEvent(new Event('change', { bubbles: true })); + }, tocMarkdown); + + // Wait for rendering to complete + await page.waitForTimeout(4000); + + // Check if headings have generated IDs + const headings = await page.evaluate(() => { + const preview = document.getElementById('markdown-preview'); + return Array.from(preview.querySelectorAll('h1, h2, h3')).map(h => ({ + text: h.textContent.trim(), + id: h.id + })); + }); + console.log("Generated Headings:", headings); + + // Confirm target heading has an ID + const section2Heading = headings.find(h => h.text === 'Section 2'); + expect(section2Heading).toBeDefined(); + expect(section2Heading.id).toBe('section-2'); + + const scrollContainerSelector = '.preview-pane'; + const editorSelector = '#markdown-editor'; + + // Get initial scroll position + const initialScrollTop = await page.evaluate((selector) => { + const container = document.querySelector(selector); + return container ? container.scrollTop : null; + }, scrollContainerSelector); + expect(initialScrollTop).toBe(0); + + const initialEditorScrollTop = await page.evaluate((selector) => { + const el = document.querySelector(selector); + return el ? el.scrollTop : null; + }, editorSelector); + expect(initialEditorScrollTop).toBe(0); + + // Click on the Section 2 link in the TOC + console.log("Clicking the Section 2 link..."); + await page.click('a[href="#section-2"]'); + + // Wait for scroll transition + await page.waitForTimeout(2000); + + // Check URL hash (should remain empty) + const currentHash = await page.evaluate(() => window.location.hash); + expect(currentHash).toBe(''); + + // Verify the container has scrolled + const afterClickScrollTop = await page.evaluate((selector) => { + const container = document.querySelector(selector); + return container ? container.scrollTop : null; + }, scrollContainerSelector); + + console.log(`Scroll position after click: ${afterClickScrollTop}`); + expect(afterClickScrollTop).toBeGreaterThan(0); + + // Verify the editor has scrolled as well + const afterClickEditorScrollTop = await page.evaluate((selector) => { + const el = document.querySelector(selector); + return el ? el.scrollTop : null; + }, editorSelector); + + console.log(`Editor scroll position after click: ${afterClickEditorScrollTop}`); + expect(afterClickEditorScrollTop).toBeGreaterThan(0); +});