mdedit's toc.js exported updateToc / clearActiveTocItem / setActiveTocItem as window.* globals, which was the only remaining violation of the two-globals discipline (only window.app and window.zddc are intended). Other tools either use IIFE + window.app.modules.X, or — like mdedit — declare top-level functions that are reachable across concatenated files without going through window. Removed the three window.* exports and unqualified the four call sites in events.js, editor.js (×3), and file-system.js. setActiveTocItem was already called bare elsewhere; the change is just dropping a window.foo && check chain that's now unnecessary. No behavior change — TOC generation and click-to-scroll work as before. mdedit's three Playwright tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
254 lines
9.5 KiB
JavaScript
254 lines
9.5 KiB
JavaScript
/**
|
|
* Table of Contents generation and scroll functionality
|
|
*/
|
|
|
|
/**
|
|
* Scroll to header service - uses line numbers for reliable targeting
|
|
*/
|
|
const ScrollToHeaderService = {
|
|
/**
|
|
* Scroll to a specific header in the editor by line number
|
|
* @param {Object} editorInstance - Toast UI Editor instance
|
|
* @param {string} headerText - Text content of the header (for highlighting)
|
|
* @param {number} lineIndex - 0-based line index of the header in markdown
|
|
*/
|
|
scrollToHeader(editorInstance, headerText, lineIndex) {
|
|
if (!editorInstance) {
|
|
console.warn('Editor instance not available for scrolling');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const editorElements = editorInstance.getEditorElements();
|
|
const isWysiwygMode = editorInstance.isWysiwygMode();
|
|
|
|
if (isWysiwygMode) {
|
|
// In WYSIWYG mode, find header by text (no line numbers available)
|
|
const wysiwygEditor = editorElements.wwEditor;
|
|
if (wysiwygEditor) {
|
|
const headers = wysiwygEditor.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
for (const header of headers) {
|
|
if (header.textContent.trim() === headerText.trim()) {
|
|
// Scroll the editor container directly with 10px offset
|
|
const headerPosition = header.getBoundingClientRect().top - wysiwygEditor.getBoundingClientRect().top;
|
|
const offset = 10; // Account for fixed headers or padding
|
|
wysiwygEditor.scrollTop = headerPosition - offset;
|
|
this._highlightHeader(header);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// In markdown mode, use line number to position cursor, then scroll preview
|
|
const lineNumber = lineIndex + 1; // Convert to 1-based
|
|
|
|
// Move cursor to the heading line in the editor
|
|
try {
|
|
editorInstance.setSelection([lineNumber, 1], [lineNumber, 1]);
|
|
} catch (e) {
|
|
if (DEBUG) console.debug('Could not set selection:', e);
|
|
}
|
|
|
|
// Scroll preview to matching header
|
|
const previewElement = editorElements.mdPreview;
|
|
if (previewElement) {
|
|
const headers = previewElement.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
|
|
for (const header of headers) {
|
|
if (header.textContent.trim() === headerText.trim()) {
|
|
// Scroll the preview container directly with 10px offset
|
|
const headerPosition = header.getBoundingClientRect().top - previewElement.getBoundingClientRect().top;
|
|
const offset = 10; // Account for fixed headers or padding
|
|
previewElement.scrollTop = headerPosition - offset;
|
|
this._highlightHeader(header);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error scrolling to header:', error);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Highlight header briefly for visual feedback
|
|
* @param {HTMLElement} headerElement - Header to highlight
|
|
*/
|
|
_highlightHeader(headerElement) {
|
|
if (!headerElement) return;
|
|
|
|
headerElement.style.transition = 'background-color 0.3s ease';
|
|
headerElement.style.backgroundColor = '#fef3c7';
|
|
|
|
setTimeout(() => {
|
|
headerElement.style.backgroundColor = '';
|
|
setTimeout(() => {
|
|
headerElement.style.transition = '';
|
|
}, 300);
|
|
}, 1500);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Generate and update the TOC from markdown content
|
|
* @param {string} content - Markdown content
|
|
* @param {HTMLElement} tocContainer - Container for the TOC
|
|
* @param {Object} editorInstance - Toast UI Editor instance
|
|
* @param {number} maxDepth - Maximum heading level (1-6)
|
|
*/
|
|
function updateToc(content, tocContainer, editorInstance, maxDepth = 6) {
|
|
if (content === undefined || content === null || !tocContainer) {
|
|
console.warn('Missing required params for updateToc');
|
|
return;
|
|
}
|
|
|
|
tocContainer.innerHTML = '';
|
|
|
|
const tocList = document.createElement('ul');
|
|
tocList.className = 'toc-list pl-0 text-sm';
|
|
|
|
if (!content.trim()) {
|
|
const emptyMessage = document.createElement('p');
|
|
emptyMessage.className = 'text-gray-500 p-4';
|
|
emptyMessage.textContent = 'This file is empty.';
|
|
tocContainer.appendChild(emptyMessage);
|
|
return;
|
|
}
|
|
|
|
const headings = [];
|
|
const lines = content.split('\n');
|
|
|
|
lines.forEach((line, index) => {
|
|
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
|
if (match) {
|
|
const level = match[1].length;
|
|
let text = match[2].trim();
|
|
|
|
// Clean markdown formatting
|
|
text = text
|
|
.replace(/\\(.)/g, '$1')
|
|
.replace(/\*\*(.*?)\*\*/g, '$1')
|
|
.replace(/\*(.*?)\*/g, '$1')
|
|
.replace(/`(.*?)`/g, '$1')
|
|
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
|
.replace(/~~(.*?)~~/g, '$1')
|
|
.trim();
|
|
|
|
const id = text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
|
|
|
|
headings.push({
|
|
level,
|
|
text,
|
|
id,
|
|
lineIndex: index
|
|
});
|
|
}
|
|
});
|
|
|
|
let currentList = tocList;
|
|
let currentLevel = 0;
|
|
let listsStack = [tocList];
|
|
|
|
const filteredHeadings = headings.filter(heading => heading.level <= maxDepth);
|
|
|
|
if (filteredHeadings.length === 0) {
|
|
const noHeadings = document.createElement('p');
|
|
noHeadings.className = 'text-gray-500 p-4';
|
|
noHeadings.textContent = maxDepth === 6 ? 'No headings found in this document.' :
|
|
'No headings at or below level H' + maxDepth + ' found.';
|
|
tocContainer.appendChild(noHeadings);
|
|
return;
|
|
}
|
|
|
|
filteredHeadings.forEach(heading => {
|
|
const li = document.createElement('li');
|
|
li.className = `toc-item toc-level-${heading.level} py-1`;
|
|
|
|
const a = document.createElement('a');
|
|
a.innerHTML = heading.text;
|
|
a.href = '#';
|
|
a.className = 'text-blue-600 hover:text-blue-800 hover:underline cursor-pointer';
|
|
a.dataset.headerText = heading.text;
|
|
a.dataset.lineIndex = heading.lineIndex;
|
|
|
|
a.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
|
|
if (editorInstance && ScrollToHeaderService) {
|
|
try {
|
|
ScrollToHeaderService.scrollToHeader(
|
|
editorInstance,
|
|
heading.text,
|
|
parseInt(heading.lineIndex)
|
|
);
|
|
} catch (error) {
|
|
console.error('Error in ScrollToHeaderService.scrollToHeader:', error);
|
|
}
|
|
}
|
|
});
|
|
|
|
li.appendChild(a);
|
|
|
|
if (heading.level > currentLevel) {
|
|
const nestedUl = document.createElement('ul');
|
|
nestedUl.className = 'pl-4 mt-1';
|
|
listsStack[listsStack.length - 1].appendChild(nestedUl);
|
|
listsStack.push(nestedUl);
|
|
currentList = nestedUl;
|
|
currentLevel = heading.level;
|
|
} else if (heading.level < currentLevel) {
|
|
while (heading.level < currentLevel && listsStack.length > 1) {
|
|
listsStack.pop();
|
|
currentLevel--;
|
|
}
|
|
currentList = listsStack[listsStack.length - 1];
|
|
}
|
|
|
|
currentList.appendChild(li);
|
|
});
|
|
|
|
tocContainer.appendChild(tocList);
|
|
clearActiveTocItem(tocContainer);
|
|
}
|
|
|
|
/**
|
|
* Clear active TOC item from all items within the container
|
|
* @param {HTMLElement} tocContainer - Container element holding the TOC
|
|
*/
|
|
function clearActiveTocItem(tocContainer) {
|
|
if (!tocContainer) return;
|
|
|
|
const activeItems = tocContainer.querySelectorAll('.toc-active');
|
|
activeItems.forEach(item => {
|
|
item.classList.remove('toc-active');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set active TOC item by finding the link matching the header text
|
|
* @param {HTMLElement} tocContainer - Container element holding the TOC
|
|
* @param {string} headerText - Text of the header to match and activate
|
|
*/
|
|
function setActiveTocItem(tocContainer, headerText) {
|
|
if (!tocContainer || !headerText) return;
|
|
|
|
// First clear any existing active items
|
|
clearActiveTocItem(tocContainer);
|
|
|
|
// Find the link matching the header text
|
|
const links = tocContainer.querySelectorAll('a[data-header-text]');
|
|
for (const link of links) {
|
|
if (link.dataset.headerText === headerText) {
|
|
// Add toc-active class to the parent li element
|
|
const li = link.parentElement;
|
|
if (li) {
|
|
li.classList.add('toc-active');
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reachable at top-level scope to other concatenated mdedit JS files via the
|
|
// build's flat-IIFE-less module pattern; no window.* exports needed.
|