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);
+});