/**
* Service responsible for DOM manipulation and UI rendering.
* @class DOMService
*/
export class DOMService {
/**
* Creates a new DOMService instance.
* @param {EventBus} eventBus - The event bus for communication.
*/
constructor(eventBus) {
/** @type {EventBus} */
this.eventBus = eventBus;
/** @type {DOMElements} */
this.elements = {
content: document.getElementById('document-content'),
outline: document.getElementById('document-outline'),
fileIndex: document.getElementById('file-index'),
titleText: document.querySelector('.title-text .page-title'),
leftSidebar: document.querySelector('.left-sidebar'),
menuButton: document.querySelector('.menu-button'),
header: document.querySelector('title-bar'),
searchInput: document.getElementById('search-input'),
searchResults: document.getElementById('search-results'),
clearSearch: document.getElementById('clear-search')
};
/** @type {number} */
this.headerOffset = 60;
}
/**
* Sets up the mobile menu toggle functionality.
*/
setupMobileMenu() {
this.elements.menuButton.addEventListener('click', () => {
const isExpanded = this.elements.leftSidebar.classList.toggle('show');
this.elements.menuButton.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
});
this.elements.content.addEventListener('click', () =>
this.elements.leftSidebar.classList.remove('show'));
}
/**
* Sets the main content area HTML.
* @param {string} html - The HTML content to render.
*/
setContent(html) {
this.elements.content.innerHTML = html;
this.elements.content.className = 'markdown-content';
hljs.highlightAll();
}
/**
* Sets the document title and page title display.
* @param {string} title - The title to set.
*/
setTitle(title) {
document.title = `${window.originalDocTitle} / ${title}`;
this.elements.titleText.textContent = title;
}
/**
* Displays an error message in the content area.
* @param {string} message - The error message to display.
*/
setError(message) {
this.elements.content.innerHTML = `
${message}
`;
}
/**
* Creates a file index item (folder or file) in the sidebar.
* @param {Document} doc - The document to create an item for.
* @param {HTMLElement} container - The container element to append to.
* @param {number} [level=0] - The nesting level for indentation.
*/
createFileIndexItem(doc, container, level = 0) {
if (doc.type === 'folder') {
this.createFolderItem(doc, container, level);
} else {
this.createFileItem(doc, container, level);
}
}
/**
* Creates a folder item in the sidebar.
* @param {Document} doc - The folder document.
* @param {HTMLElement} container - The container element.
* @param {number} level - The nesting level.
* @private
*/
createFolderItem(doc, container, level) {
const folderDiv = document.createElement('div');
const isOpen = doc.defaultOpen === true;
folderDiv.className = 'folder' + (isOpen ? ' open' : '');
folderDiv.dataset.path = doc.title;
folderDiv.style.paddingLeft = `${level * 0.8}rem`;
const folderHeader = document.createElement('div');
folderHeader.className = 'folder-header';
folderHeader.setAttribute('role', 'treeitem');
folderHeader.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
folderHeader.setAttribute('tabindex', '0');
const iconClass = doc.icon || `fas fa-folder${isOpen ? '-open' : ''}`;
if (doc.path && doc.metadata?.showfolderpage !== false) {
folderHeader.innerHTML = this.createFolderHeaderWithFile(iconClass, doc);
this.setupFolderListeners(folderDiv, folderHeader, doc);
} else {
folderHeader.innerHTML = this.createFolderHeaderBasic(iconClass, doc);
this.setupBasicFolderListeners(folderDiv, folderHeader, doc);
}
const folderContent = document.createElement('div');
folderContent.className = 'folder-content';
doc.items.forEach(item => this.createFileIndexItem(item, folderContent, level + 1));
folderDiv.appendChild(folderHeader);
folderDiv.appendChild(folderContent);
container.appendChild(folderDiv);
}
/**
* Sets up listeners for basic folder toggle (no folder page).
* @param {HTMLElement} folderDiv - The folder container element.
* @param {HTMLElement} folderHeader - The folder header element.
* @param {Document} doc - The folder document.
* @private
*/
setupBasicFolderListeners(folderDiv, folderHeader, doc) {
folderHeader.addEventListener('click', () => {
folderDiv.classList.toggle('open');
const isExpanded = folderDiv.classList.contains('open');
folderHeader.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
if (!doc.icon) {
const icon = folderHeader.querySelector('.folder-icon');
icon.classList.toggle('fa-folder-closed');
icon.classList.toggle('fa-folder-open');
}
});
folderHeader.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
folderHeader.click();
}
});
}
/**
* Creates a file item link in the sidebar.
* @param {Document} doc - The file document.
* @param {HTMLElement} container - The container element.
* @param {number} level - The nesting level.
* @private
*/
createFileItem(doc, container, level) {
const link = document.createElement('a');
link.href = `?${doc.slug}`;
link.textContent = doc.title || doc.path.split('/').pop().replace('.md', '');
link.dataset.path = doc.path;
link.style.paddingLeft = `${(level * 0.6) + 0.8}rem`;
link.setAttribute('role', 'treeitem');
link.onclick = (e) => {
e.preventDefault();
this.eventBus.emit('navigation:requested', { slug: doc.slug });
history.pushState(null, '', link.href);
if (window.innerWidth <= 1000) {
this.elements.leftSidebar.classList.remove('show');
}
};
container.appendChild(link);
}
/**
* Updates the active document highlight in the sidebar.
* @param {string} path - The path of the active document.
*/
updateActiveDocument(path) {
this.elements.fileIndex.querySelectorAll('a').forEach(link => {
link.classList.toggle('active', link.dataset.path === path);
});
}
/**
* Creates the document outline from headings.
* @param {NodeListOf} headings - The heading elements.
* @returns {Map} Map of headings to their outline links.
*/
createOutline(headings) {
this.elements.outline.innerHTML = '';
const headingLinks = new Map();
headings.forEach(heading => {
if (!heading.id) {
heading.id = heading.textContent.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
}
const link = this.createOutlineLink(heading);
headingLinks.set(heading, link);
this.elements.outline.appendChild(link);
this.addHeadingFoldToggle(heading);
});
return headingLinks;
}
/**
* Creates HTML for a folder header with a file link.
* @param {string} iconClass - The icon CSS class.
* @param {Document} doc - The folder document.
* @returns {string} The HTML string.
* @private
*/
createFolderHeaderWithFile(iconClass, doc) {
const showFolderPage = doc.showfolderpage !== 'false';
return `
${doc.title}
${showFolderPage ? `
` : ''}`;
}
/**
* Creates HTML for a basic folder header without file link.
* @param {string} iconClass - The icon CSS class.
* @param {Document} doc - The folder document.
* @returns {string} The HTML string.
* @private
*/
createFolderHeaderBasic(iconClass, doc) {
return `
${doc.title}`;
}
/**
* Sets up event listeners for a folder with a navigable page.
* @param {HTMLElement} folderDiv - The folder container element.
* @param {HTMLElement} folderHeader - The folder header element.
* @param {Document} doc - The folder document.
* @private
*/
setupFolderListeners(folderDiv, folderHeader, doc) {
folderHeader.addEventListener('click', (e) => {
if (e.target.closest('.folder-link')) {
e.preventDefault();
this.eventBus.emit('navigation:requested', { slug: doc.slug });
history.pushState(null, '', `?${doc.slug}`);
if (window.innerWidth <= 1000) {
this.elements.leftSidebar.classList.remove('show');
}
return;
}
folderDiv.classList.toggle('open');
const isExpanded = folderDiv.classList.contains('open');
folderHeader.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
if (!doc.icon) {
const icon = folderHeader.querySelector('.folder-icon');
icon.classList.toggle('fa-folder-closed');
icon.classList.toggle('fa-folder-open');
}
});
folderHeader.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (e.target.closest('.folder-link')) {
e.target.closest('.folder-link').click();
} else {
folderHeader.click();
}
}
});
}
/**
* Scrolls smoothly to an element with header offset.
* @param {HTMLElement} element - The element to scroll to.
*/
scrollToElement(element) {
if (!element) return;
const rect = element.getBoundingClientRect();
const absoluteElementTop = rect.top + window.scrollY;
const middle = absoluteElementTop - (this.headerOffset + 20);
window.scrollTo({
top: middle,
behavior: 'smooth'
});
}
/**
* Creates an outline link for a heading.
* @param {HTMLHeadingElement} heading - The heading element.
* @returns {HTMLAnchorElement} The created link element.
* @private
*/
createOutlineLink(heading) {
const link = document.createElement('a');
link.href = `${window.location.pathname}${window.location.search}#${heading.id}`;
link.textContent = heading.textContent;
link.style.paddingLeft = (heading.tagName[1] * 15) + 'px';
link.onclick = (e) => {
e.preventDefault();
history.pushState(null, '', link.href);
this.scrollToElement(heading);
heading.classList.remove('highlight');
void heading.offsetWidth;
heading.classList.add('highlight');
};
return link;
}
/**
* Adds a fold/unfold toggle button to a heading.
* @param {HTMLHeadingElement} heading - The heading element.
* @private
*/
addHeadingFoldToggle(heading) {
const toggleBtn = document.createElement('span');
toggleBtn.innerHTML = ``;
toggleBtn.style.cursor = 'pointer';
toggleBtn.style.userSelect = 'none';
toggleBtn.style.marginLeft = '0.5em';
toggleBtn.style.display = 'inline-flex';
toggleBtn.style.alignItems = 'center';
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
const svg = toggleBtn.querySelector('svg');
const isFolded = svg.style.transform === 'rotate(90deg)';
svg.style.transform = isFolded ? 'rotate(0deg)' : 'rotate(90deg)';
let next = heading.nextElementSibling;
const currentLevel = parseInt(heading.tagName[1]);
while (next) {
if (!/^H[1-6]$/.test(next.tagName)) {
next.style.display = isFolded ? 'none' : '';
next = next.nextElementSibling;
} else {
const nextLevel = parseInt(next.tagName[1]);
if (nextLevel <= currentLevel) break;
next.style.display = isFolded ? 'none' : '';
next = next.nextElementSibling;
}
}
});
heading.appendChild(toggleBtn);
}
/**
* Sets up search input event handlers.
* @param {SearchService} searchService - The search service instance.
*/
setupSearch(searchService) {
let searchTimeout;
this.elements.searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
const query = e.target.value;
searchTimeout = setTimeout(() => {
const results = searchService.search(query);
this.renderSearchResults(results);
}, 200);
});
this.elements.clearSearch.addEventListener('click', () => {
this.elements.searchInput.value = '';
this.elements.searchResults.innerHTML = '';
this.elements.searchResults.style.display = 'none';
this.elements.searchResults.setAttribute('aria-hidden', 'true');
});
}
/**
* Renders search results in the dropdown.
* @param {Array} results - The search results to render.
*/
renderSearchResults(results) {
const container = this.elements.searchResults;
container.innerHTML = '';
if (results.length === 0 || !this.elements.searchInput.value) {
container.style.display = 'none';
container.setAttribute('aria-hidden', 'true');
return;
}
container.setAttribute('aria-hidden', 'false');
const resultCount = results.length === 1 ? '1 search result found' : `${results.length} search results found`;
container.setAttribute('aria-label', resultCount);
results.forEach(result => {
const div = document.createElement('div');
div.className = 'search-result';
const icon = document.createElement('i');
icon.className = result.type === 'folder' ? 'fas fa-folder' :
result.type === 'header' ? 'fas fa-hashtag' : 'fas fa-file-alt';
const link = document.createElement('a');
link.href = `?${result.slug}`;
link.innerHTML = `
${icon.outerHTML}
${result.title}
${result.location}
`;
link.addEventListener('click', (e) => {
e.preventDefault();
this.elements.searchInput.value = '';
container.style.display = 'none';
const [baseSlug, hash] = result.slug.split('#');
history.pushState(null, '', link.href);
this.eventBus.emit('navigation:requested', {
slug: baseSlug,
hash: hash ? `#${hash}` : '',
fromSearch: true
});
});
div.appendChild(link);
container.appendChild(div);
});
container.style.display = 'block';
}
}
/**
* @typedef {Object} DOMElements
* @property {HTMLElement} content - The main content container.
* @property {HTMLElement} outline - The document outline container.
* @property {HTMLElement} fileIndex - The file index container.
* @property {HTMLElement} titleText - The page title element.
* @property {HTMLElement} leftSidebar - The left sidebar element.
* @property {HTMLElement} menuButton - The mobile menu button.
* @property {HTMLElement} header - The header element.
* @property {HTMLInputElement} searchInput - The search input field.
* @property {HTMLElement} searchResults - The search results container.
* @property {HTMLElement} clearSearch - The clear search button.
*/