ZDDC/mdedit/js/toc.js
ZDDC ea385b5366 Initial commit
ZDDC — Zero Day Document Control. A file-naming convention plus five
single-file HTML tools (archive, transmittal, classifier, mdedit,
landing) and an optional Go HTTP server (zddc-server) with ACL and a
virtual archive index. Self-contained, offline-capable, dependency-free.

See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the
build/release/architecture detail, bootstrap/README.md for the
two-level deployment install pattern, and zddc/README.md for the
HTTP server.
2026-04-27 11:05:47 -05:00

256 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;
}
}
}
// Export globally
window.updateToc = updateToc;
window.clearActiveTocItem = clearActiveTocItem;
window.setActiveTocItem = setActiveTocItem;