- Add functionality to create new collections via API - Implement copy and move operations between collections - Improve image rendering in markdown preview with relative path resolution - Add support for previewing binary files (images, PDFs) - Refactor modal styling to use flat buttons and improve accessibility
496 lines
17 KiB
JavaScript
496 lines
17 KiB
JavaScript
/**
|
|
* 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 `<div class="alert alert-warning">⚠️ Invalid image data detected. Please re-upload the image.</div>`;
|
|
}
|
|
|
|
// Fix relative image paths to use WebDAV base URL
|
|
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) {
|
|
// Get the directory of the current file
|
|
const currentDir = this.currentFile ? PathUtils.getParentPath(this.currentFile) : '';
|
|
|
|
// Resolve relative path
|
|
let imagePath = href;
|
|
if (href.startsWith('./')) {
|
|
// Relative to current directory
|
|
imagePath = PathUtils.joinPaths(currentDir, href.substring(2));
|
|
} else if (href.startsWith('../')) {
|
|
// Relative to parent directory
|
|
imagePath = PathUtils.joinPaths(currentDir, href);
|
|
} else if (!href.startsWith('/')) {
|
|
// Relative to current directory (no ./)
|
|
imagePath = PathUtils.joinPaths(currentDir, href);
|
|
} else {
|
|
// Absolute path from collection root
|
|
imagePath = href.substring(1); // Remove leading /
|
|
}
|
|
|
|
// Build WebDAV URL - ensure no double slashes
|
|
if (this.webdavClient && this.webdavClient.currentCollection) {
|
|
// Remove trailing slash from baseUrl if present
|
|
const baseUrl = this.webdavClient.baseUrl.endsWith('/')
|
|
? this.webdavClient.baseUrl.slice(0, -1)
|
|
: this.webdavClient.baseUrl;
|
|
|
|
// Ensure imagePath doesn't start with /
|
|
const cleanImagePath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath;
|
|
|
|
href = `${baseUrl}/${this.webdavClient.currentCollection}/${cleanImagePath}`;
|
|
|
|
Logger.debug(`Resolved image URL: ${href}`);
|
|
}
|
|
}
|
|
|
|
// Generate HTML directly
|
|
const titleAttr = title ? ` title="${title}"` : '';
|
|
const altAttr = text ? ` alt="${text}"` : '';
|
|
return `<img src="${href}"${altAttr}${titleAttr}>`;
|
|
};
|
|
|
|
this.marked.setOptions({
|
|
breaks: true,
|
|
gfm: true,
|
|
renderer: renderer,
|
|
highlight: (code, lang) => {
|
|
if (lang && window.Prism.languages[lang]) {
|
|
return window.Prism.highlight(code, window.Prism.languages[lang], lang);
|
|
}
|
|
return code;
|
|
}
|
|
});
|
|
} else {
|
|
console.error('Marked library not found.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize Mermaid
|
|
*/
|
|
initMermaid() {
|
|
if (window.mermaid) {
|
|
window.mermaid.initialize({
|
|
startOnLoad: false,
|
|
theme: document.body.classList.contains('dark-mode') ? 'dark' : 'default'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set WebDAV client
|
|
*/
|
|
setWebDAVClient(client) {
|
|
this.webdavClient = client;
|
|
|
|
// Update macro processor with client
|
|
if (this.macroProcessor) {
|
|
this.macroProcessor.webdavClient = client;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load file
|
|
*/
|
|
async loadFile(path) {
|
|
try {
|
|
// Reset custom preview flag when loading text files
|
|
this.isShowingCustomPreview = false;
|
|
|
|
const content = await this.webdavClient.get(path);
|
|
this.currentFile = path;
|
|
|
|
// Update filename input if it exists
|
|
if (this.filenameInput) {
|
|
this.filenameInput.value = path;
|
|
}
|
|
|
|
// Update editor if it exists (edit mode)
|
|
if (this.editor) {
|
|
this.editor.setValue(content);
|
|
}
|
|
|
|
// Update preview with the loaded content
|
|
await this.renderPreview(content);
|
|
|
|
// Save as last viewed page
|
|
this.saveLastViewedPage(path);
|
|
// No notification for successful file load - it's not critical
|
|
} catch (error) {
|
|
console.error('Failed to load file:', error);
|
|
if (window.showNotification) {
|
|
window.showNotification('Failed to load file', 'danger');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save the last viewed page to localStorage
|
|
* Stores per collection so different collections can have different last viewed pages
|
|
*/
|
|
saveLastViewedPage(path) {
|
|
if (!this.webdavClient || !this.webdavClient.currentCollection) {
|
|
return;
|
|
}
|
|
const collection = this.webdavClient.currentCollection;
|
|
const storageKey = `${this.lastViewedStorageKey}:${collection}`;
|
|
localStorage.setItem(storageKey, path);
|
|
}
|
|
|
|
/**
|
|
* Get the last viewed page from localStorage
|
|
* Returns null if no page was previously viewed
|
|
*/
|
|
getLastViewedPage() {
|
|
if (!this.webdavClient || !this.webdavClient.currentCollection) {
|
|
return null;
|
|
}
|
|
const collection = this.webdavClient.currentCollection;
|
|
const storageKey = `${this.lastViewedStorageKey}:${collection}`;
|
|
return localStorage.getItem(storageKey);
|
|
}
|
|
|
|
/**
|
|
* Save file
|
|
*/
|
|
async save() {
|
|
const path = this.filenameInput.value.trim();
|
|
if (!path) {
|
|
if (window.showNotification) {
|
|
window.showNotification('Please enter a filename', 'warning');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const content = this.editor.getValue();
|
|
|
|
try {
|
|
await this.webdavClient.put(path, content);
|
|
this.currentFile = path;
|
|
|
|
if (window.showNotification) {
|
|
window.showNotification('✅ Saved', 'success');
|
|
}
|
|
|
|
// Dispatch event to reload file tree
|
|
if (window.eventBus) {
|
|
window.eventBus.dispatch('file-saved', path);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to save file:', error);
|
|
if (window.showNotification) {
|
|
window.showNotification('Failed to save file', 'danger');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create new file
|
|
*/
|
|
newFile() {
|
|
this.currentFile = null;
|
|
this.filenameInput.value = '';
|
|
this.filenameInput.focus();
|
|
this.editor.setValue('# New File\n\nStart typing...\n');
|
|
this.updatePreview();
|
|
// No notification needed - UI is self-explanatory
|
|
}
|
|
|
|
/**
|
|
* Delete current file
|
|
*/
|
|
async deleteFile() {
|
|
if (!this.currentFile) {
|
|
window.showNotification('No file selected', 'warning');
|
|
return;
|
|
}
|
|
|
|
const confirmed = await window.ConfirmationManager.confirm(`Are you sure you want to delete ${this.currentFile}?`, 'Delete File', true);
|
|
if (confirmed) {
|
|
try {
|
|
await this.webdavClient.delete(this.currentFile);
|
|
window.showNotification(`Deleted ${this.currentFile}`, 'success');
|
|
this.newFile();
|
|
window.eventBus.dispatch('file-deleted');
|
|
} catch (error) {
|
|
console.error('Failed to delete file:', error);
|
|
window.showNotification('Failed to delete file', 'danger');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render preview from markdown content
|
|
* Can be called with explicit content (for view mode) or from editor (for edit mode)
|
|
*/
|
|
async renderPreview(markdownContent = null) {
|
|
// Get markdown content from editor if not provided
|
|
const markdown = markdownContent !== null ? markdownContent : (this.editor ? this.editor.getValue() : '');
|
|
const previewDiv = this.previewElement;
|
|
|
|
if (!markdown || !markdown.trim()) {
|
|
previewDiv.innerHTML = `
|
|
<div class="text-muted text-center mt-5">
|
|
<p>Start typing to see preview...</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Step 1: Process macros
|
|
let processedContent = markdown;
|
|
|
|
if (this.macroProcessor) {
|
|
const processingResult = await this.macroProcessor.processMacros(markdown);
|
|
processedContent = processingResult.content;
|
|
}
|
|
|
|
// Step 2: Parse markdown to HTML
|
|
if (!this.marked) {
|
|
console.error("Markdown parser (marked) not initialized.");
|
|
previewDiv.innerHTML = `<div class="alert alert-danger">Preview engine not loaded.</div>`;
|
|
return;
|
|
}
|
|
|
|
let html = this.marked.parse(processedContent);
|
|
|
|
// Replace mermaid code blocks
|
|
html = html.replace(
|
|
/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g,
|
|
(match, code) => {
|
|
const id = 'mermaid-' + Math.random().toString(36).substr(2, 9);
|
|
return `<div class="mermaid" id="${id}">${code.trim()}</div>`;
|
|
}
|
|
);
|
|
|
|
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 = `
|
|
<div class="alert alert-danger" role="alert">
|
|
<strong>Error rendering preview:</strong><br>
|
|
${error.message}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|