/** * Editor Module * Handles CodeMirror editor and markdown preview */ class MarkdownEditor { constructor(editorId, previewId, filenameInputId, readOnly = false) { this.editorElement = document.getElementById(editorId); this.previewElement = document.getElementById(previewId); this.filenameInput = document.getElementById(filenameInputId); this.currentFile = null; this.webdavClient = null; this.macroProcessor = new MacroProcessor(null); // Will be set later this.lastViewedStorageKey = 'lastViewedPage'; // localStorage key for tracking last viewed page this.readOnly = readOnly; // Whether editor is in read-only mode this.editor = null; // Will be initialized later this.isShowingCustomPreview = false; // Flag to prevent auto-update when showing binary files // Only initialize CodeMirror if not in read-only mode (view mode) if (!readOnly) { this.initCodeMirror(); } this.initMarkdown(); this.initMermaid(); } /** * Initialize CodeMirror */ initCodeMirror() { // Determine theme based on dark mode const isDarkMode = document.body.classList.contains('dark-mode'); const theme = isDarkMode ? 'monokai' : 'default'; this.editor = CodeMirror(this.editorElement, { mode: 'markdown', theme: theme, lineNumbers: true, lineWrapping: true, autofocus: !this.readOnly, // Don't autofocus in read-only mode readOnly: this.readOnly, // Set read-only mode extraKeys: this.readOnly ? {} : { 'Ctrl-S': () => this.save(), 'Cmd-S': () => this.save() } }); // Update preview on change with debouncing this.editor.on('change', TimingUtils.debounce(() => { this.updatePreview(); }, Config.DEBOUNCE_DELAY)); // Initial preview render setTimeout(() => { this.updatePreview(); }, 100); // Sync scroll this.editor.on('scroll', () => { this.syncScroll(); }); // Listen for dark mode changes this.setupThemeListener(); } /** * Setup listener for dark mode changes */ setupThemeListener() { // Watch for dark mode class changes const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.attributeName === 'class') { const isDarkMode = document.body.classList.contains('dark-mode'); const newTheme = isDarkMode ? 'monokai' : 'default'; this.editor.setOption('theme', newTheme); } }); }); observer.observe(document.body, { attributes: true }); } /** * Initialize markdown parser */ initMarkdown() { if (window.marked) { this.marked = window.marked; // Create custom renderer for images const renderer = new marked.Renderer(); renderer.image = (token) => { // Handle both old API (string params) and new API (token object) let href, title, text; if (typeof token === 'object' && token !== null) { // New API: token is an object href = token.href || ''; title = token.title || ''; text = token.text || ''; } else { // Old API: separate parameters (href, title, text) href = arguments[0] || ''; title = arguments[1] || ''; text = arguments[2] || ''; } // Ensure all are strings href = String(href || ''); title = String(title || ''); text = String(text || ''); Logger.debug(`Image renderer called with href="${href}", title="${title}", text="${text}"`); // Check if href contains binary data (starts with non-printable characters) if (href && href.length > 100 && /^[\x00-\x1F\x7F-\xFF]/.test(href)) { Logger.error('Image href contains binary data - this should not happen!'); Logger.error('First 50 chars:', href.substring(0, 50)); // Return a placeholder image return `
Start typing to see preview...
([\s\S]*?)<\/code><\/pre>/g,
(match, code) => {
const id = 'mermaid-' + Math.random().toString(36).substr(2, 9);
return `${code.trim()}`;
}
);
previewDiv.innerHTML = html;
// Apply syntax highlighting
const codeBlocks = previewDiv.querySelectorAll('pre code');
codeBlocks.forEach(block => {
const languageClass = Array.from(block.classList)
.find(cls => cls.startsWith('language-'));
if (languageClass && languageClass !== 'language-mermaid') {
if (window.Prism) {
window.Prism.highlightElement(block);
}
}
});
// Render mermaid diagrams
const mermaidElements = previewDiv.querySelectorAll('.mermaid');
if (mermaidElements.length > 0 && window.mermaid) {
try {
window.mermaid.contentLoaded();
} catch (error) {
console.warn('Mermaid rendering error:', error);
}
}
} catch (error) {
console.error('Preview rendering error:', error);
previewDiv.innerHTML = `
Error rendering preview:
${error.message}
`;
}
}
/**
* Update preview (backward compatibility wrapper)
* Calls renderPreview with content from editor
*/
async updatePreview() {
// Skip auto-update if showing custom preview (e.g., binary files)
if (this.isShowingCustomPreview) {
Logger.debug('Skipping auto-update: showing custom preview');
return;
}
if (this.editor) {
await this.renderPreview();
}
}
/**
* Sync scroll between editor and preview
*/
syncScroll() {
if (!this.editor) return; // Skip if no editor (view mode)
const scrollInfo = this.editor.getScrollInfo();
const scrollPercent = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight);
const previewHeight = this.previewElement.scrollHeight - this.previewElement.clientHeight;
this.previewElement.scrollTop = previewHeight * scrollPercent;
}
/**
* Handle image upload
*/
async uploadImage(file) {
try {
const filename = await this.webdavClient.uploadImage(file);
const imageUrl = `/fs/${this.webdavClient.currentCollection}/images/${filename}`;
const markdown = ``;
// Insert at cursor
this.editor.replaceSelection(markdown);
if (window.showNotification) {
window.showNotification('Image uploaded', 'success');
}
} catch (error) {
console.error('Failed to upload image:', error);
if (window.showNotification) {
window.showNotification('Failed to upload image', 'danger');
}
}
}
/**
* Get editor content
*/
getValue() {
return this.editor.getValue();
}
insertAtCursor(text) {
const doc = this.editor.getDoc();
const cursor = doc.getCursor();
doc.replaceRange(text, cursor);
}
/**
* Set editor content
*/
setValue(content) {
this.editor.setValue(content);
}
// Debounce function moved to TimingUtils in utils.js
}
// Export for use in other modules
window.MarkdownEditor = MarkdownEditor;