refactor: Modularize UI components and utilities

- Extract UI components into separate JS files
- Centralize configuration values in config.js
- Introduce a dedicated logger module
- Improve file tree drag-and-drop and undo functionality
- Refactor modal handling to a single manager
- Add URL routing support for SPA navigation
- Implement view mode for read-only access
This commit is contained in:
Mahmoud-Emad
2025-10-26 15:42:15 +03:00
parent 23a24d42e2
commit 0ed6bcf1f2
34 changed files with 4136 additions and 940 deletions

View File

@@ -12,100 +12,430 @@ let collectionSelector;
let clipboard = null;
let currentFilePath = null;
// Simple event bus
const eventBus = {
listeners: {},
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
// Event bus is now loaded from event-bus.js module
// No need to define it here - it's available as window.eventBus
/**
* Auto-load page in view mode
* Tries to load the last viewed page, falls back to first file if none saved
*/
async function autoLoadPageInViewMode() {
if (!editor || !fileTree) return;
try {
// Try to get last viewed page
let pageToLoad = editor.getLastViewedPage();
// If no last viewed page, get the first markdown file
if (!pageToLoad) {
pageToLoad = fileTree.getFirstMarkdownFile();
}
this.listeners[event].push(callback);
},
dispatch(event, data) {
if (this.listeners[event]) {
this.listeners[event].forEach(callback => callback(data));
// If we found a page to load, load it
if (pageToLoad) {
await editor.loadFile(pageToLoad);
// Highlight the file in the tree and expand parent directories
fileTree.selectAndExpandPath(pageToLoad);
} else {
// No files found, show empty state message
editor.previewElement.innerHTML = `
<div class="text-muted text-center mt-5">
<p>No content available</p>
</div>
`;
}
} catch (error) {
console.error('Failed to auto-load page in view mode:', error);
editor.previewElement.innerHTML = `
<div class="alert alert-danger">
<p>Failed to load content</p>
</div>
`;
}
};
window.eventBus = eventBus;
}
/**
* Show directory preview with list of files
* @param {string} dirPath - The directory path
*/
async function showDirectoryPreview(dirPath) {
if (!editor || !fileTree || !webdavClient) return;
try {
const dirName = dirPath.split('/').pop() || dirPath;
const files = fileTree.getDirectoryFiles(dirPath);
// Start building the preview HTML
let html = `<div class="directory-preview">`;
html += `<h2>${dirName}</h2>`;
if (files.length === 0) {
html += `<p>This directory is empty</p>`;
} else {
html += `<div class="directory-files">`;
// Create cards for each file
for (const file of files) {
const fileName = file.name;
let fileDescription = '';
// Try to get file description from markdown files
if (file.name.endsWith('.md')) {
try {
const content = await webdavClient.get(file.path);
// Extract first heading or first line as description
const lines = content.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
fileDescription = line.replace(/^#+\s*/, '').trim();
break;
} else if (line.trim() && !line.startsWith('---')) {
fileDescription = line.trim().substring(0, 100);
break;
}
}
} catch (error) {
console.error('Failed to read file description:', error);
}
}
html += `
<div class="file-card" data-path="${file.path}">
<div class="file-card-header">
<i class="bi bi-file-earmark-text"></i>
<span class="file-card-name">${fileName}</span>
</div>
${fileDescription ? `<div class="file-card-description">${fileDescription}</div>` : ''}
</div>
`;
}
html += `</div>`;
}
html += `</div>`;
// Set the preview content
editor.previewElement.innerHTML = html;
// Add click handlers to file cards
editor.previewElement.querySelectorAll('.file-card').forEach(card => {
card.addEventListener('click', async () => {
const filePath = card.dataset.path;
await editor.loadFile(filePath);
fileTree.selectAndExpandPath(filePath);
});
});
} catch (error) {
console.error('Failed to show directory preview:', error);
editor.previewElement.innerHTML = `
<div class="alert alert-danger">
<p>Failed to load directory preview</p>
</div>
`;
}
}
/**
* Parse URL to extract collection and file path
* URL format: /<collection>/<file_path> or /<collection>/<dir>/<file>
* @returns {Object} {collection, filePath} or {collection, null} if only collection
*/
function parseURLPath() {
const pathname = window.location.pathname;
const parts = pathname.split('/').filter(p => p); // Remove empty parts
if (parts.length === 0) {
return { collection: null, filePath: null };
}
const collection = parts[0];
const filePath = parts.length > 1 ? parts.slice(1).join('/') : null;
return { collection, filePath };
}
/**
* Update URL based on current collection and file
* @param {string} collection - The collection name
* @param {string} filePath - The file path (optional)
* @param {boolean} isEditMode - Whether in edit mode
*/
function updateURL(collection, filePath, isEditMode) {
let url = `/${collection}`;
if (filePath) {
url += `/${filePath}`;
}
if (isEditMode) {
url += '?edit=true';
}
// Use pushState to update URL without reloading
window.history.pushState({ collection, filePath }, '', url);
}
/**
* Load file from URL path
* Assumes the collection is already set and file tree is loaded
* @param {string} collection - The collection name (for validation)
* @param {string} filePath - The file path
*/
async function loadFileFromURL(collection, filePath) {
console.log('[loadFileFromURL] Called with:', { collection, filePath });
if (!fileTree || !editor || !collectionSelector) {
console.error('[loadFileFromURL] Missing dependencies:', { fileTree: !!fileTree, editor: !!editor, collectionSelector: !!collectionSelector });
return;
}
try {
// Verify we're on the right collection
const currentCollection = collectionSelector.getCurrentCollection();
if (currentCollection !== collection) {
console.error(`[loadFileFromURL] Collection mismatch: expected ${collection}, got ${currentCollection}`);
return;
}
// Load the file or directory
if (filePath) {
// Check if the path is a directory or a file
const node = fileTree.findNode(filePath);
console.log('[loadFileFromURL] Found node:', node);
if (node && node.isDirectory) {
// It's a directory, show directory preview
console.log('[loadFileFromURL] Loading directory preview');
await showDirectoryPreview(filePath);
fileTree.selectAndExpandPath(filePath);
} else if (node) {
// It's a file, load it
console.log('[loadFileFromURL] Loading file');
await editor.loadFile(filePath);
fileTree.selectAndExpandPath(filePath);
} else {
console.error(`[loadFileFromURL] Path not found in file tree: ${filePath}`);
}
}
} catch (error) {
console.error('[loadFileFromURL] Failed to load file from URL:', error);
}
}
/**
* Handle browser back/forward navigation
*/
function setupPopStateListener() {
window.addEventListener('popstate', async (event) => {
const { collection, filePath } = parseURLPath();
if (collection) {
// Ensure the collection is set
const currentCollection = collectionSelector.getCurrentCollection();
if (currentCollection !== collection) {
await collectionSelector.setCollection(collection);
await fileTree.load();
}
// Load the file/directory
await loadFileFromURL(collection, filePath);
}
});
}
// Initialize application
document.addEventListener('DOMContentLoaded', async () => {
// Determine view mode from URL parameter
const urlParams = new URLSearchParams(window.location.search);
const isEditMode = urlParams.get('edit') === 'true';
// Set view mode class on body
if (isEditMode) {
document.body.classList.add('edit-mode');
document.body.classList.remove('view-mode');
} else {
document.body.classList.add('view-mode');
document.body.classList.remove('edit-mode');
}
// Initialize WebDAV client
webdavClient = new WebDAVClient('/fs/');
// Initialize dark mode
darkMode = new DarkMode();
document.getElementById('darkModeBtn').addEventListener('click', () => {
darkMode.toggle();
});
// Initialize file tree
fileTree = new FileTree('fileTree', webdavClient);
fileTree.onFileSelect = async (item) => {
await editor.loadFile(item.path);
};
// Initialize collection selector
// Initialize collection selector (always needed)
collectionSelector = new CollectionSelector('collectionSelect', webdavClient);
collectionSelector.onChange = async (collection) => {
await fileTree.load();
};
await collectionSelector.load();
await fileTree.load();
// Initialize editor
editor = new MarkdownEditor('editor', 'preview', 'filenameInput');
// Setup URL routing
setupPopStateListener();
// Initialize editor (always needed for preview)
// In view mode, editor is read-only
editor = new MarkdownEditor('editor', 'preview', 'filenameInput', !isEditMode);
editor.setWebDAVClient(webdavClient);
// Add test content to verify preview works
setTimeout(() => {
if (!editor.editor.getValue()) {
editor.editor.setValue('# Welcome to Markdown Editor\n\nStart typing to see preview...\n');
editor.updatePreview();
// Initialize file tree (needed in both modes)
fileTree = new FileTree('fileTree', webdavClient);
fileTree.onFileSelect = async (item) => {
try {
await editor.loadFile(item.path);
// Highlight the file in the tree and expand parent directories
fileTree.selectAndExpandPath(item.path);
// Update URL to reflect current file
const currentCollection = collectionSelector.getCurrentCollection();
updateURL(currentCollection, item.path, isEditMode);
} catch (error) {
Logger.error('Failed to select file:', error);
if (window.showNotification) {
window.showNotification('Failed to load file', 'error');
}
}
}, 200);
// Setup editor drop handler
const editorDropHandler = new EditorDropHandler(
document.querySelector('.editor-container'),
async (file) => {
await handleEditorFileDrop(file);
}
);
// Setup button handlers
document.getElementById('newBtn').addEventListener('click', () => {
editor.newFile();
});
document.getElementById('saveBtn').addEventListener('click', async () => {
await editor.save();
});
document.getElementById('deleteBtn').addEventListener('click', async () => {
await editor.deleteFile();
});
// Setup context menu handlers
setupContextMenuHandlers();
// Initialize mermaid
mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' });
};
// Initialize file tree actions manager
window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor);
fileTree.onFolderSelect = async (item) => {
try {
// Show directory preview
await showDirectoryPreview(item.path);
// Highlight the directory in the tree and expand parent directories
fileTree.selectAndExpandPath(item.path);
// Update URL to reflect current directory
const currentCollection = collectionSelector.getCurrentCollection();
updateURL(currentCollection, item.path, isEditMode);
} catch (error) {
Logger.error('Failed to select folder:', error);
if (window.showNotification) {
window.showNotification('Failed to load folder', 'error');
}
}
};
collectionSelector.onChange = async (collection) => {
try {
await fileTree.load();
// In view mode, auto-load last viewed page when collection changes
if (!isEditMode) {
await autoLoadPageInViewMode();
}
} catch (error) {
Logger.error('Failed to change collection:', error);
if (window.showNotification) {
window.showNotification('Failed to change collection', 'error');
}
}
};
await fileTree.load();
// Parse URL to load file if specified
const { collection: urlCollection, filePath: urlFilePath } = parseURLPath();
console.log('[URL PARSE]', { urlCollection, urlFilePath });
if (urlCollection && urlFilePath) {
console.log('[URL LOAD] Loading from URL:', urlCollection, urlFilePath);
// First ensure the collection is set
const currentCollection = collectionSelector.getCurrentCollection();
if (currentCollection !== urlCollection) {
console.log('[URL LOAD] Switching collection from', currentCollection, 'to', urlCollection);
await collectionSelector.setCollection(urlCollection);
await fileTree.load();
}
// Now load the file from URL
console.log('[URL LOAD] Calling loadFileFromURL');
await loadFileFromURL(urlCollection, urlFilePath);
} else if (!isEditMode) {
// In view mode, auto-load last viewed page if no URL file specified
await autoLoadPageInViewMode();
}
// Initialize file tree and editor-specific features only in edit mode
if (isEditMode) {
// Add test content to verify preview works
setTimeout(() => {
if (!editor.editor.getValue()) {
editor.editor.setValue('# Welcome to Markdown Editor\n\nStart typing to see preview...\n');
editor.updatePreview();
}
}, 200);
// Setup editor drop handler
const editorDropHandler = new EditorDropHandler(
document.querySelector('.editor-container'),
async (file) => {
try {
await handleEditorFileDrop(file);
} catch (error) {
Logger.error('Failed to handle file drop:', error);
}
}
);
// Setup button handlers
document.getElementById('newBtn').addEventListener('click', () => {
editor.newFile();
});
document.getElementById('saveBtn').addEventListener('click', async () => {
try {
await editor.save();
} catch (error) {
Logger.error('Failed to save file:', error);
if (window.showNotification) {
window.showNotification('Failed to save file', 'error');
}
}
});
document.getElementById('deleteBtn').addEventListener('click', async () => {
try {
await editor.deleteFile();
} catch (error) {
Logger.error('Failed to delete file:', error);
if (window.showNotification) {
window.showNotification('Failed to delete file', 'error');
}
}
});
// Setup context menu handlers
setupContextMenuHandlers();
// Initialize file tree actions manager
window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor);
} else {
// In view mode, hide editor buttons
document.getElementById('newBtn').style.display = 'none';
document.getElementById('saveBtn').style.display = 'none';
document.getElementById('deleteBtn').style.display = 'none';
// Auto-load last viewed page or first file
await autoLoadPageInViewMode();
}
// Initialize mermaid (always needed)
mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' });
// Listen for file-saved event to reload file tree
window.eventBus.on('file-saved', async (path) => {
if (fileTree) {
await fileTree.load();
fileTree.selectNode(path);
try {
if (fileTree) {
await fileTree.load();
fileTree.selectNode(path);
}
} catch (error) {
Logger.error('Failed to reload file tree after save:', error);
}
});
window.eventBus.on('file-deleted', async () => {
if (fileTree) {
await fileTree.load();
try {
if (fileTree) {
await fileTree.load();
}
} catch (error) {
Logger.error('Failed to reload file tree after delete:', error);
}
});
});
@@ -126,17 +456,17 @@ window.addEventListener('column-resize', () => {
*/
function setupContextMenuHandlers() {
const menu = document.getElementById('contextMenu');
menu.addEventListener('click', async (e) => {
const item = e.target.closest('.context-menu-item');
if (!item) return;
const action = item.dataset.action;
const targetPath = menu.dataset.targetPath;
const isDir = menu.dataset.targetIsDir === 'true';
hideContextMenu();
await window.fileTreeActions.execute(action, targetPath, isDir);
});
}
@@ -163,16 +493,16 @@ async function handleEditorFileDrop(file) {
parts.pop(); // Remove filename
targetDir = parts.join('/');
}
// Upload file
const uploadedPath = await fileTree.uploadFile(targetDir, file);
// Insert markdown link at cursor
const isImage = file.type.startsWith('image/');
const link = isImage
const link = isImage
? `![${file.name}](/${webdavClient.currentCollection}/${uploadedPath})`
: `[${file.name}](/${webdavClient.currentCollection}/${uploadedPath})`;
editor.insertAtCursor(link);
showNotification(`Uploaded and inserted link`, 'success');
} catch (error) {

View File

@@ -0,0 +1,100 @@
/**
* Collection Selector Module
* Manages the collection dropdown selector and persistence
*/
class CollectionSelector {
constructor(selectId, webdavClient) {
this.select = document.getElementById(selectId);
this.webdavClient = webdavClient;
this.onChange = null;
this.storageKey = Config.STORAGE_KEYS.SELECTED_COLLECTION;
}
/**
* Load collections from WebDAV and populate the selector
*/
async load() {
try {
const collections = await this.webdavClient.getCollections();
this.select.innerHTML = '';
collections.forEach(collection => {
const option = document.createElement('option');
option.value = collection;
option.textContent = collection;
this.select.appendChild(option);
});
// Try to restore previously selected collection from localStorage
const savedCollection = localStorage.getItem(this.storageKey);
let collectionToSelect = collections[0]; // Default to first
if (savedCollection && collections.includes(savedCollection)) {
collectionToSelect = savedCollection;
}
if (collections.length > 0) {
this.select.value = collectionToSelect;
this.webdavClient.setCollection(collectionToSelect);
if (this.onChange) {
this.onChange(collectionToSelect);
}
}
// Add change listener
this.select.addEventListener('change', () => {
const collection = this.select.value;
// Save to localStorage
localStorage.setItem(this.storageKey, collection);
this.webdavClient.setCollection(collection);
Logger.info(`Collection changed to: ${collection}`);
if (this.onChange) {
this.onChange(collection);
}
});
Logger.debug(`Loaded ${collections.length} collections`);
} catch (error) {
Logger.error('Failed to load collections:', error);
if (window.showNotification) {
window.showNotification('Failed to load collections', 'error');
}
}
}
/**
* Get the currently selected collection
* @returns {string} The collection name
*/
getCurrentCollection() {
return this.select.value;
}
/**
* Set the collection to a specific value
* @param {string} collection - The collection name to set
*/
async setCollection(collection) {
const collections = Array.from(this.select.options).map(opt => opt.value);
if (collections.includes(collection)) {
this.select.value = collection;
localStorage.setItem(this.storageKey, collection);
this.webdavClient.setCollection(collection);
Logger.info(`Collection set to: ${collection}`);
if (this.onChange) {
this.onChange(collection);
}
} else {
Logger.warn(`Collection "${collection}" not found in available collections`);
}
}
}
// Make CollectionSelector globally available
window.CollectionSelector = CollectionSelector;

View File

@@ -10,68 +10,67 @@ class ColumnResizer {
this.sidebarPane = document.getElementById('sidebarPane');
this.editorPane = document.getElementById('editorPane');
this.previewPane = document.getElementById('previewPane');
// Load saved dimensions
this.loadDimensions();
// Setup listeners
this.setupResizers();
}
setupResizers() {
this.resizer1.addEventListener('mousedown', (e) => this.startResize(e, 1));
this.resizer2.addEventListener('mousedown', (e) => this.startResize(e, 2));
}
startResize(e, resizerId) {
e.preventDefault();
const startX = e.clientX;
const startWidth1 = this.sidebarPane.offsetWidth;
const startWidth2 = this.editorPane.offsetWidth;
const containerWidth = this.sidebarPane.parentElement.offsetWidth;
const resizer = resizerId === 1 ? this.resizer1 : this.resizer2;
resizer.classList.add('dragging');
const handleMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
if (resizerId === 1) {
// Resize sidebar and editor
const newWidth1 = Math.max(150, Math.min(40 * containerWidth / 100, startWidth1 + deltaX));
const newWidth2 = startWidth2 - (newWidth1 - startWidth1);
this.sidebarPane.style.flex = `0 0 ${newWidth1}px`;
this.editorPane.style.flex = `1 1 ${newWidth2}px`;
} else if (resizerId === 2) {
// Resize editor and preview
const newWidth2 = Math.max(250, Math.min(70 * containerWidth / 100, startWidth2 + deltaX));
const containerFlex = this.sidebarPane.offsetWidth;
this.editorPane.style.flex = `0 0 ${newWidth2}px`;
this.previewPane.style.flex = `1 1 auto`;
}
};
const handleMouseUp = () => {
resizer.classList.remove('dragging');
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
// Save dimensions
this.saveDimensions();
// Trigger editor resize
if (window.editor && window.editor.editor) {
window.editor.editor.refresh();
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
saveDimensions() {
const dimensions = {
sidebar: this.sidebarPane.offsetWidth,
@@ -80,16 +79,15 @@ class ColumnResizer {
};
localStorage.setItem('columnDimensions', JSON.stringify(dimensions));
}
loadDimensions() {
const saved = localStorage.getItem('columnDimensions');
if (!saved) return;
try {
const { sidebar, editor, preview } = JSON.parse(saved);
this.sidebarPane.style.flex = `0 0 ${sidebar}px`;
this.editorPane.style.flex = `0 0 ${editor}px`;
this.previewPane.style.flex = `1 1 auto`;
} catch (error) {
console.error('Failed to load column dimensions:', error);
}

202
static/js/config.js Normal file
View File

@@ -0,0 +1,202 @@
/**
* Application Configuration
* Centralized configuration values for the markdown editor
*/
const Config = {
// ===== TIMING CONFIGURATION =====
/**
* Long-press threshold in milliseconds
* Used for drag-and-drop detection in file tree
*/
LONG_PRESS_THRESHOLD: 400,
/**
* Debounce delay in milliseconds
* Used for editor preview updates
*/
DEBOUNCE_DELAY: 300,
/**
* Toast notification duration in milliseconds
*/
TOAST_DURATION: 3000,
/**
* Mouse move threshold in pixels
* Used to detect if user is dragging vs clicking
*/
MOUSE_MOVE_THRESHOLD: 5,
// ===== UI CONFIGURATION =====
/**
* Drag preview width in pixels
* Width of the drag ghost image during drag-and-drop
*/
DRAG_PREVIEW_WIDTH: 200,
/**
* Tree indentation in pixels
* Indentation per level in the file tree
*/
TREE_INDENT_PX: 12,
/**
* Toast container z-index
* Ensures toasts appear above other elements
*/
TOAST_Z_INDEX: 9999,
/**
* Minimum sidebar width in pixels
*/
MIN_SIDEBAR_WIDTH: 150,
/**
* Maximum sidebar width as percentage of container
*/
MAX_SIDEBAR_WIDTH_PERCENT: 40,
/**
* Minimum editor width in pixels
*/
MIN_EDITOR_WIDTH: 250,
/**
* Maximum editor width as percentage of container
*/
MAX_EDITOR_WIDTH_PERCENT: 70,
// ===== VALIDATION CONFIGURATION =====
/**
* Valid filename pattern
* Only lowercase letters, numbers, underscores, and dots allowed
*/
FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/,
/**
* Characters to replace in filenames
* All invalid characters will be replaced with underscore
*/
FILENAME_INVALID_CHARS: /[^a-z0-9_.]/g,
// ===== STORAGE KEYS =====
/**
* LocalStorage keys used throughout the application
*/
STORAGE_KEYS: {
/**
* Dark mode preference
*/
DARK_MODE: 'darkMode',
/**
* Currently selected collection
*/
SELECTED_COLLECTION: 'selectedCollection',
/**
* Last viewed page (per collection)
* Actual key will be: lastViewedPage:{collection}
*/
LAST_VIEWED_PAGE: 'lastViewedPage',
/**
* Column dimensions (sidebar, editor, preview widths)
*/
COLUMN_DIMENSIONS: 'columnDimensions'
},
// ===== EDITOR CONFIGURATION =====
/**
* CodeMirror theme for light mode
*/
EDITOR_THEME_LIGHT: 'default',
/**
* CodeMirror theme for dark mode
*/
EDITOR_THEME_DARK: 'monokai',
/**
* Mermaid theme for light mode
*/
MERMAID_THEME_LIGHT: 'default',
/**
* Mermaid theme for dark mode
*/
MERMAID_THEME_DARK: 'dark',
// ===== FILE TREE CONFIGURATION =====
/**
* Default content for new files
*/
DEFAULT_FILE_CONTENT: '# New File\n\n',
/**
* Default filename for new files
*/
DEFAULT_NEW_FILENAME: 'new_file.md',
/**
* Default folder name for new folders
*/
DEFAULT_NEW_FOLDERNAME: 'new_folder',
// ===== WEBDAV CONFIGURATION =====
/**
* WebDAV base URL
*/
WEBDAV_BASE_URL: '/fs/',
/**
* PROPFIND depth for file tree loading
*/
PROPFIND_DEPTH: 'infinity',
// ===== DRAG AND DROP CONFIGURATION =====
/**
* Drag preview opacity
*/
DRAG_PREVIEW_OPACITY: 0.8,
/**
* Dragging item opacity
*/
DRAGGING_OPACITY: 0.4,
/**
* Drag preview offset X in pixels
*/
DRAG_PREVIEW_OFFSET_X: 10,
/**
* Drag preview offset Y in pixels
*/
DRAG_PREVIEW_OFFSET_Y: 10,
// ===== NOTIFICATION TYPES =====
/**
* Bootstrap notification type mappings
*/
NOTIFICATION_TYPES: {
SUCCESS: 'success',
ERROR: 'danger',
WARNING: 'warning',
INFO: 'primary'
}
};
// Make Config globally available
window.Config = Config;

View File

@@ -1,68 +1,169 @@
/**
* Confirmation Modal Manager
* Unified Modal Manager
* Handles showing and hiding a Bootstrap modal for confirmations and prompts.
* Uses a single reusable modal element to prevent double-opening issues.
*/
class Confirmation {
class ModalManager {
constructor(modalId) {
this.modalElement = document.getElementById(modalId);
this.modal = new bootstrap.Modal(this.modalElement);
if (!this.modalElement) {
console.error(`Modal element with id "${modalId}" not found`);
return;
}
this.modal = new bootstrap.Modal(this.modalElement, {
backdrop: 'static',
keyboard: true
});
this.messageElement = this.modalElement.querySelector('#confirmationMessage');
this.inputElement = this.modalElement.querySelector('#confirmationInput');
this.confirmButton = this.modalElement.querySelector('#confirmButton');
this.cancelButton = this.modalElement.querySelector('[data-bs-dismiss="modal"]');
this.titleElement = this.modalElement.querySelector('.modal-title');
this.currentResolver = null;
this.isShowing = false;
}
_show(message, title, showInput = false, defaultValue = '') {
/**
* Show a confirmation dialog
* @param {string} message - The message to display
* @param {string} title - The dialog title
* @param {boolean} isDangerous - Whether this is a dangerous action (shows red button)
* @returns {Promise<boolean>} - Resolves to true if confirmed, false/null if cancelled
*/
confirm(message, title = 'Confirmation', isDangerous = false) {
return new Promise((resolve) => {
// Prevent double-opening
if (this.isShowing) {
console.warn('Modal is already showing, ignoring duplicate request');
resolve(null);
return;
}
this.isShowing = true;
this.currentResolver = resolve;
this.titleElement.textContent = title;
this.messageElement.textContent = message;
this.inputElement.style.display = 'none';
if (showInput) {
this.inputElement.style.display = 'block';
this.inputElement.value = defaultValue;
this.inputElement.focus();
// Update button styling based on danger level
if (isDangerous) {
this.confirmButton.className = 'btn btn-danger';
this.confirmButton.textContent = 'Delete';
} else {
this.inputElement.style.display = 'none';
this.confirmButton.className = 'btn btn-primary';
this.confirmButton.textContent = 'OK';
}
this.confirmButton.onclick = () => this._handleConfirm(showInput);
this.modalElement.addEventListener('hidden.bs.modal', () => this._handleCancel(), { once: true });
// Set up event handlers
this.confirmButton.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
this._handleConfirm(false);
};
// Handle modal hidden event for cleanup
this.modalElement.addEventListener('hidden.bs.modal', () => {
if (this.currentResolver) {
this._handleCancel();
}
}, { once: true });
this.modal.show();
// Focus confirm button after modal is shown
this.modalElement.addEventListener('shown.bs.modal', () => {
this.confirmButton.focus();
}, { once: true });
});
}
/**
* Show a prompt dialog (input dialog)
* @param {string} message - The message/label to display
* @param {string} defaultValue - The default input value
* @param {string} title - The dialog title
* @returns {Promise<string|null>} - Resolves to input value if confirmed, null if cancelled
*/
prompt(message, defaultValue = '', title = 'Input') {
return new Promise((resolve) => {
// Prevent double-opening
if (this.isShowing) {
console.warn('Modal is already showing, ignoring duplicate request');
resolve(null);
return;
}
this.isShowing = true;
this.currentResolver = resolve;
this.titleElement.textContent = title;
this.messageElement.textContent = message;
this.inputElement.style.display = 'block';
this.inputElement.value = defaultValue;
// Reset button to primary style for prompts
this.confirmButton.className = 'btn btn-primary';
this.confirmButton.textContent = 'OK';
// Set up event handlers
this.confirmButton.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
this._handleConfirm(true);
};
// Handle Enter key in input
this.inputElement.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this._handleConfirm(true);
}
};
// Handle modal hidden event for cleanup
this.modalElement.addEventListener('hidden.bs.modal', () => {
if (this.currentResolver) {
this._handleCancel();
}
}, { once: true });
this.modal.show();
// Focus and select input after modal is shown
this.modalElement.addEventListener('shown.bs.modal', () => {
this.inputElement.focus();
this.inputElement.select();
}, { once: true });
});
}
_handleConfirm(isPrompt) {
if (this.currentResolver) {
const value = isPrompt ? this.inputElement.value : true;
this.currentResolver(value);
const value = isPrompt ? this.inputElement.value.trim() : true;
const resolver = this.currentResolver;
this._cleanup();
resolver(value);
}
}
_handleCancel() {
if (this.currentResolver) {
this.currentResolver(null); // Resolve with null for cancellation
const resolver = this.currentResolver;
this._cleanup();
resolver(null);
}
}
_cleanup() {
this.confirmButton.onclick = null;
this.modal.hide();
this.inputElement.onkeydown = null;
this.currentResolver = null;
}
confirm(message, title = 'Confirmation') {
return this._show(message, title, false);
}
prompt(message, defaultValue = '', title = 'Prompt') {
return this._show(message, title, true, defaultValue);
this.isShowing = false;
this.modal.hide();
}
}
// Make it globally available
window.ConfirmationManager = new Confirmation('confirmationModal');
window.ConfirmationManager = new ModalManager('confirmationModal');
window.ModalManager = window.ConfirmationManager; // Alias for clarity

89
static/js/context-menu.js Normal file
View File

@@ -0,0 +1,89 @@
/**
* Context Menu Module
* Handles the right-click context menu for file tree items
*/
/**
* Show context menu at specified position
* @param {number} x - X coordinate
* @param {number} y - Y coordinate
* @param {Object} target - Target object with path and isDir properties
*/
function showContextMenu(x, y, target) {
const menu = document.getElementById('contextMenu');
if (!menu) return;
// Store target data
menu.dataset.targetPath = target.path;
menu.dataset.targetIsDir = target.isDir;
// Show/hide menu items based on target type
const items = {
'new-file': target.isDir,
'new-folder': target.isDir,
'upload': target.isDir,
'download': true,
'paste': target.isDir && window.fileTreeActions?.clipboard,
'open': !target.isDir
};
Object.entries(items).forEach(([action, show]) => {
const item = menu.querySelector(`[data-action="${action}"]`);
if (item) {
item.style.display = show ? 'flex' : 'none';
}
});
// Position menu
menu.style.display = 'block';
menu.style.left = x + 'px';
menu.style.top = y + 'px';
// Adjust if off-screen
setTimeout(() => {
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menu.style.left = (window.innerWidth - rect.width - 10) + 'px';
}
if (rect.bottom > window.innerHeight) {
menu.style.top = (window.innerHeight - rect.height - 10) + 'px';
}
}, 0);
}
/**
* Hide the context menu
*/
function hideContextMenu() {
const menu = document.getElementById('contextMenu');
if (menu) {
menu.style.display = 'none';
}
}
// Combined click handler for context menu and outside clicks
document.addEventListener('click', async (e) => {
const menuItem = e.target.closest('.context-menu-item');
if (menuItem) {
// Handle context menu item click
const action = menuItem.dataset.action;
const menu = document.getElementById('contextMenu');
const targetPath = menu.dataset.targetPath;
const isDir = menu.dataset.targetIsDir === 'true';
hideContextMenu();
if (window.fileTreeActions) {
await window.fileTreeActions.execute(action, targetPath, isDir);
}
} else if (!e.target.closest('#contextMenu') && !e.target.closest('.tree-node')) {
// Hide on outside click
hideContextMenu();
}
});
// Make functions globally available
window.showContextMenu = showContextMenu;
window.hideContextMenu = hideContextMenu;

77
static/js/dark-mode.js Normal file
View File

@@ -0,0 +1,77 @@
/**
* Dark Mode Module
* Manages dark mode theme switching and persistence
*/
class DarkMode {
constructor() {
this.isDark = localStorage.getItem(Config.STORAGE_KEYS.DARK_MODE) === 'true';
this.apply();
}
/**
* Toggle dark mode on/off
*/
toggle() {
this.isDark = !this.isDark;
localStorage.setItem(Config.STORAGE_KEYS.DARK_MODE, this.isDark);
this.apply();
Logger.debug(`Dark mode ${this.isDark ? 'enabled' : 'disabled'}`);
}
/**
* Apply the current dark mode state
*/
apply() {
if (this.isDark) {
document.body.classList.add('dark-mode');
const btn = document.getElementById('darkModeBtn');
if (btn) btn.textContent = '☀️';
// Update mermaid theme
if (window.mermaid) {
mermaid.initialize({ theme: Config.MERMAID_THEME_DARK });
}
} else {
document.body.classList.remove('dark-mode');
const btn = document.getElementById('darkModeBtn');
if (btn) btn.textContent = '🌙';
// Update mermaid theme
if (window.mermaid) {
mermaid.initialize({ theme: Config.MERMAID_THEME_LIGHT });
}
}
}
/**
* Check if dark mode is currently enabled
* @returns {boolean} True if dark mode is enabled
*/
isEnabled() {
return this.isDark;
}
/**
* Enable dark mode
*/
enable() {
if (!this.isDark) {
this.toggle();
}
}
/**
* Disable dark mode
*/
disable() {
if (this.isDark) {
this.toggle();
}
}
}
// Make DarkMode globally available
window.DarkMode = DarkMode;

View File

@@ -0,0 +1,67 @@
/**
* Editor Drop Handler Module
* Handles file drops into the editor for uploading
*/
class EditorDropHandler {
constructor(editorElement, onFileDrop) {
this.editorElement = editorElement;
this.onFileDrop = onFileDrop;
this.setupHandlers();
}
/**
* Setup drag and drop event handlers
*/
setupHandlers() {
this.editorElement.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
this.editorElement.classList.add('drag-over');
});
this.editorElement.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
this.editorElement.classList.remove('drag-over');
});
this.editorElement.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
this.editorElement.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
Logger.debug(`Dropped ${files.length} file(s) into editor`);
for (const file of files) {
try {
if (this.onFileDrop) {
await this.onFileDrop(file);
}
} catch (error) {
Logger.error('Drop failed:', error);
if (window.showNotification) {
window.showNotification(`Failed to upload ${file.name}`, 'error');
}
}
}
});
}
/**
* Remove event handlers
*/
destroy() {
// Note: We can't easily remove the event listeners without keeping references
// This is a limitation of the current implementation
// In a future refactor, we could store the bound handlers
Logger.debug('EditorDropHandler destroyed');
}
}
// Make EditorDropHandler globally available
window.EditorDropHandler = EditorDropHandler;

View File

@@ -4,15 +4,21 @@
*/
class MarkdownEditor {
constructor(editorId, previewId, filenameInputId) {
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.initCodeMirror();
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
// Only initialize CodeMirror if not in read-only mode (view mode)
if (!readOnly) {
this.initCodeMirror();
}
this.initMarkdown();
this.initMermaid();
}
@@ -21,22 +27,27 @@ class MarkdownEditor {
* 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: 'monokai',
theme: theme,
lineNumbers: true,
lineWrapping: true,
autofocus: true,
extraKeys: {
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', this.debounce(() => {
this.editor.on('change', TimingUtils.debounce(() => {
this.updatePreview();
}, 300));
}, Config.DEBOUNCE_DELAY));
// Initial preview render
setTimeout(() => {
@@ -47,6 +58,27 @@ class MarkdownEditor {
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 });
}
/**
@@ -87,7 +119,7 @@ class MarkdownEditor {
*/
setWebDAVClient(client) {
this.webdavClient = client;
// Update macro processor with client
if (this.macroProcessor) {
this.macroProcessor.webdavClient = client;
@@ -101,13 +133,23 @@ class MarkdownEditor {
try {
const content = await this.webdavClient.get(path);
this.currentFile = path;
this.filenameInput.value = path;
this.editor.setValue(content);
this.updatePreview();
if (window.showNotification) {
window.showNotification(`Loaded ${path}`, 'info');
// 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) {
@@ -116,6 +158,32 @@ class MarkdownEditor {
}
}
/**
* 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
*/
@@ -133,7 +201,7 @@ class MarkdownEditor {
try {
await this.webdavClient.put(path, content);
this.currentFile = path;
if (window.showNotification) {
window.showNotification('✅ Saved', 'success');
}
@@ -159,10 +227,7 @@ class MarkdownEditor {
this.filenameInput.focus();
this.editor.setValue('# New File\n\nStart typing...\n');
this.updatePreview();
if (window.showNotification) {
window.showNotification('Enter filename and start typing', 'info');
}
// No notification needed - UI is self-explanatory
}
/**
@@ -174,7 +239,7 @@ class MarkdownEditor {
return;
}
const confirmed = await window.ConfirmationManager.confirm(`Are you sure you want to delete ${this.currentFile}?`, 'Delete File');
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);
@@ -189,10 +254,12 @@ class MarkdownEditor {
}
/**
* Update preview
* Render preview from markdown content
* Can be called with explicit content (for view mode) or from editor (for edit mode)
*/
async updatePreview() {
const markdown = this.editor.getValue();
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()) {
@@ -207,24 +274,19 @@ class MarkdownEditor {
try {
// Step 1: Process macros
let processedContent = markdown;
if (this.macroProcessor) {
const processingResult = await this.macroProcessor.processMacros(markdown);
processedContent = processingResult.content;
// Log errors if any
if (processingResult.errors.length > 0) {
console.warn('Macro processing errors:', processingResult.errors);
}
}
// 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
@@ -270,13 +332,25 @@ class MarkdownEditor {
}
}
/**
* Update preview (backward compatibility wrapper)
* Calls renderPreview with content from editor
*/
async updatePreview() {
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;
}
@@ -289,10 +363,10 @@ class MarkdownEditor {
const filename = await this.webdavClient.uploadImage(file);
const imageUrl = `/fs/${this.webdavClient.currentCollection}/images/${filename}`;
const markdown = `![${file.name}](${imageUrl})`;
// Insert at cursor
this.editor.replaceSelection(markdown);
if (window.showNotification) {
window.showNotification('Image uploaded', 'success');
}
@@ -310,7 +384,7 @@ class MarkdownEditor {
getValue() {
return this.editor.getValue();
}
insertAtCursor(text) {
const doc = this.editor.getDoc();
const cursor = doc.getCursor();
@@ -324,20 +398,7 @@ class MarkdownEditor {
this.editor.setValue(content);
}
/**
* Debounce function
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Debounce function moved to TimingUtils in utils.js
}
// Export for use in other modules

126
static/js/event-bus.js Normal file
View File

@@ -0,0 +1,126 @@
/**
* Event Bus Module
* Provides a centralized event system for application-wide communication
* Allows components to communicate without tight coupling
*/
class EventBus {
constructor() {
/**
* Map of event names to arrays of listener functions
* @type {Object.<string, Function[]>}
*/
this.listeners = {};
}
/**
* Register an event listener
* @param {string} event - The event name to listen for
* @param {Function} callback - The function to call when the event is dispatched
* @returns {Function} A function to unregister this listener
*/
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
// Return unsubscribe function
return () => this.off(event, callback);
}
/**
* Register a one-time event listener
* The listener will be automatically removed after being called once
* @param {string} event - The event name to listen for
* @param {Function} callback - The function to call when the event is dispatched
* @returns {Function} A function to unregister this listener
*/
once(event, callback) {
const onceWrapper = (data) => {
callback(data);
this.off(event, onceWrapper);
};
return this.on(event, onceWrapper);
}
/**
* Unregister an event listener
* @param {string} event - The event name
* @param {Function} callback - The callback function to remove
*/
off(event, callback) {
if (!this.listeners[event]) {
return;
}
this.listeners[event] = this.listeners[event].filter(
listener => listener !== callback
);
// Clean up empty listener arrays
if (this.listeners[event].length === 0) {
delete this.listeners[event];
}
}
/**
* Dispatch an event to all registered listeners
* @param {string} event - The event name to dispatch
* @param {any} data - The data to pass to the listeners
*/
dispatch(event, data) {
if (!this.listeners[event]) {
return;
}
// Create a copy of the listeners array to avoid issues if listeners are added/removed during dispatch
const listeners = [...this.listeners[event]];
listeners.forEach(callback => {
try {
callback(data);
} catch (error) {
Logger.error(`Error in event listener for "${event}":`, error);
}
});
}
/**
* Remove all listeners for a specific event
* If no event is specified, removes all listeners for all events
* @param {string} [event] - The event name (optional)
*/
clear(event) {
if (event) {
delete this.listeners[event];
} else {
this.listeners = {};
}
}
/**
* Get the number of listeners for an event
* @param {string} event - The event name
* @returns {number} The number of listeners
*/
listenerCount(event) {
return this.listeners[event] ? this.listeners[event].length : 0;
}
/**
* Get all event names that have listeners
* @returns {string[]} Array of event names
*/
eventNames() {
return Object.keys(this.listeners);
}
}
// Create and export the global event bus instance
const eventBus = new EventBus();
// Make it globally available
window.eventBus = eventBus;
window.EventBus = EventBus;

View File

@@ -14,32 +14,10 @@ class FileTreeActions {
/**
* Validate and sanitize filename/folder name
* Returns { valid: boolean, sanitized: string, message: string }
* Now uses ValidationUtils from utils.js
*/
validateFileName(name, isFolder = false) {
const type = isFolder ? 'folder' : 'file';
if (!name || name.trim().length === 0) {
return { valid: false, message: `${type} name cannot be empty` };
}
// Check for invalid characters
const validPattern = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/;
if (!validPattern.test(name)) {
const sanitized = name
.toLowerCase()
.replace(/[^a-z0-9_.]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
return {
valid: false,
sanitized,
message: `Invalid characters in ${type} name. Only lowercase letters, numbers, and underscores allowed.\n\nSuggestion: "${sanitized}"`
};
}
return { valid: true, sanitized: name, message: '' };
return ValidationUtils.validateFileName(name, isFolder);
}
async execute(action, targetPath, isDirectory) {
@@ -48,7 +26,7 @@ class FileTreeActions {
console.error(`Unknown action: ${action}`);
return;
}
try {
await handler.call(this, targetPath, isDirectory);
} catch (error) {
@@ -58,140 +36,198 @@ class FileTreeActions {
}
actions = {
open: async function(path, isDir) {
open: async function (path, isDir) {
if (!isDir) {
await this.editor.loadFile(path);
}
},
'new-file': async function(path, isDir) {
'new-file': async function (path, isDir) {
if (!isDir) return;
await this.showInputDialog('Enter filename (lowercase, underscore only):', 'new_file.md', async (filename) => {
if (!filename) return;
const validation = this.validateFileName(filename, false);
if (!validation.valid) {
showNotification(validation.message, 'warning');
// Ask if user wants to use sanitized version
if (validation.sanitized) {
if (await this.showConfirmDialog('Use sanitized name?', `${filename}${validation.sanitized}`)) {
filename = validation.sanitized;
} else {
return;
}
const filename = await window.ModalManager.prompt(
'Enter filename (lowercase, underscore only):',
'new_file.md',
'New File'
);
if (!filename) return;
let finalFilename = filename;
const validation = this.validateFileName(filename, false);
if (!validation.valid) {
showNotification(validation.message, 'warning');
// Ask if user wants to use sanitized version
if (validation.sanitized) {
const useSanitized = await window.ModalManager.confirm(
`${filename}${validation.sanitized}`,
'Use sanitized name?',
false
);
if (useSanitized) {
finalFilename = validation.sanitized;
} else {
return;
}
} else {
return;
}
const fullPath = `${path}/${filename}`.replace(/\/+/g, '/');
await this.webdavClient.put(fullPath, '# New File\n\n');
await this.fileTree.load();
showNotification(`Created ${filename}`, 'success');
await this.editor.loadFile(fullPath);
});
}
const fullPath = `${path}/${finalFilename}`.replace(/\/+/g, '/');
await this.webdavClient.put(fullPath, '# New File\n\n');
// Clear undo history since new file was created
if (this.fileTree.lastMoveOperation) {
this.fileTree.lastMoveOperation = null;
}
await this.fileTree.load();
showNotification(`Created ${finalFilename}`, 'success');
await this.editor.loadFile(fullPath);
},
'new-folder': async function(path, isDir) {
'new-folder': async function (path, isDir) {
if (!isDir) return;
await this.showInputDialog('Enter folder name (lowercase, underscore only):', 'new_folder', async (foldername) => {
if (!foldername) return;
const validation = this.validateFileName(foldername, true);
if (!validation.valid) {
showNotification(validation.message, 'warning');
if (validation.sanitized) {
if (await this.showConfirmDialog('Use sanitized name?', `${foldername}${validation.sanitized}`)) {
foldername = validation.sanitized;
} else {
return;
}
const foldername = await window.ModalManager.prompt(
'Enter folder name (lowercase, underscore only):',
'new_folder',
'New Folder'
);
if (!foldername) return;
let finalFoldername = foldername;
const validation = this.validateFileName(foldername, true);
if (!validation.valid) {
showNotification(validation.message, 'warning');
if (validation.sanitized) {
const useSanitized = await window.ModalManager.confirm(
`${foldername}${validation.sanitized}`,
'Use sanitized name?',
false
);
if (useSanitized) {
finalFoldername = validation.sanitized;
} else {
return;
}
} else {
return;
}
const fullPath = `${path}/${foldername}`.replace(/\/+/g, '/');
await this.webdavClient.mkcol(fullPath);
await this.fileTree.load();
showNotification(`Created folder ${foldername}`, 'success');
});
}
const fullPath = `${path}/${finalFoldername}`.replace(/\/+/g, '/');
await this.webdavClient.mkcol(fullPath);
// Clear undo history since new folder was created
if (this.fileTree.lastMoveOperation) {
this.fileTree.lastMoveOperation = null;
}
await this.fileTree.load();
showNotification(`Created folder ${finalFoldername}`, 'success');
},
rename: async function(path, isDir) {
rename: async function (path, isDir) {
const oldName = path.split('/').pop();
const newName = await this.showInputDialog('Rename to:', oldName);
const newName = await window.ModalManager.prompt(
'Rename to:',
oldName,
'Rename'
);
if (newName && newName !== oldName) {
const parentPath = path.substring(0, path.lastIndexOf('/'));
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
await this.webdavClient.move(path, newPath);
// Clear undo history since manual rename occurred
if (this.fileTree.lastMoveOperation) {
this.fileTree.lastMoveOperation = null;
}
await this.fileTree.load();
showNotification('Renamed', 'success');
}
},
copy: async function(path, isDir) {
copy: async function (path, isDir) {
this.clipboard = { path, operation: 'copy', isDirectory: isDir };
showNotification(`Copied: ${path.split('/').pop()}`, 'info');
// No notification for copy - it's a quick operation
this.updatePasteMenuItem();
},
cut: async function(path, isDir) {
cut: async function (path, isDir) {
this.clipboard = { path, operation: 'cut', isDirectory: isDir };
showNotification(`Cut: ${path.split('/').pop()}`, 'warning');
// No notification for cut - it's a quick operation
this.updatePasteMenuItem();
},
paste: async function(targetPath, isDir) {
paste: async function (targetPath, isDir) {
if (!this.clipboard || !isDir) return;
const itemName = this.clipboard.path.split('/').pop();
const destPath = `${targetPath}/${itemName}`.replace(/\/+/g, '/');
if (this.clipboard.operation === 'copy') {
await this.webdavClient.copy(this.clipboard.path, destPath);
showNotification('Copied', 'success');
// No notification for paste - file tree updates show the result
} else {
await this.webdavClient.move(this.clipboard.path, destPath);
this.clipboard = null;
this.updatePasteMenuItem();
showNotification('Moved', 'success');
// No notification for move - file tree updates show the result
}
await this.fileTree.load();
},
delete: async function(path, isDir) {
delete: async function (path, isDir) {
const name = path.split('/').pop();
const type = isDir ? 'folder' : 'file';
if (!await this.showConfirmDialog(`Delete this ${type}?`, `${name}`)) {
return;
}
const confirmed = await window.ModalManager.confirm(
`Are you sure you want to delete ${name}?`,
`Delete this ${type}?`,
true
);
if (!confirmed) return;
await this.webdavClient.delete(path);
// Clear undo history since manual delete occurred
if (this.fileTree.lastMoveOperation) {
this.fileTree.lastMoveOperation = null;
}
await this.fileTree.load();
showNotification(`Deleted ${name}`, 'success');
},
download: async function(path, isDir) {
showNotification('Downloading...', 'info');
// Implementation here
download: async function (path, isDir) {
Logger.info(`Downloading ${isDir ? 'folder' : 'file'}: ${path}`);
if (isDir) {
await this.fileTree.downloadFolder(path);
} else {
await this.fileTree.downloadFile(path);
}
},
upload: async function(path, isDir) {
upload: async function (path, isDir) {
if (!isDir) return;
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.onchange = async (e) => {
const files = Array.from(e.target.files);
for (const file of files) {
@@ -202,156 +238,12 @@ class FileTreeActions {
}
await this.fileTree.load();
};
input.click();
}
};
// Modern dialog implementations
async showInputDialog(title, placeholder = '', callback) {
return new Promise((resolve) => {
const dialog = this.createInputDialog(title, placeholder);
const input = dialog.querySelector('input');
const confirmBtn = dialog.querySelector('.btn-primary');
const cancelBtn = dialog.querySelector('.btn-secondary');
const cleanup = (value) => {
const modalInstance = bootstrap.Modal.getInstance(dialog);
if (modalInstance) {
modalInstance.hide();
}
dialog.remove();
const backdrop = document.querySelector('.modal-backdrop');
if (backdrop) backdrop.remove();
document.body.classList.remove('modal-open');
resolve(value);
if (callback) callback(value);
};
confirmBtn.onclick = () => {
cleanup(input.value.trim());
};
cancelBtn.onclick = () => {
cleanup(null);
};
dialog.addEventListener('hidden.bs.modal', () => {
cleanup(null);
});
input.onkeypress = (e) => {
if (e.key === 'Enter') confirmBtn.click();
};
document.body.appendChild(dialog);
const modal = new bootstrap.Modal(dialog);
modal.show();
input.focus();
input.select();
});
}
async showConfirmDialog(title, message = '', callback) {
return new Promise((resolve) => {
const dialog = this.createConfirmDialog(title, message);
const confirmBtn = dialog.querySelector('.btn-danger');
const cancelBtn = dialog.querySelector('.btn-secondary');
const cleanup = (value) => {
const modalInstance = bootstrap.Modal.getInstance(dialog);
if (modalInstance) {
modalInstance.hide();
}
dialog.remove();
const backdrop = document.querySelector('.modal-backdrop');
if (backdrop) backdrop.remove();
document.body.classList.remove('modal-open');
resolve(value);
if (callback) callback(value);
};
confirmBtn.onclick = () => {
cleanup(true);
};
cancelBtn.onclick = () => {
cleanup(false);
};
dialog.addEventListener('hidden.bs.modal', () => {
cleanup(false);
});
document.body.appendChild(dialog);
const modal = new bootstrap.Modal(dialog);
modal.show();
confirmBtn.focus();
});
}
createInputDialog(title, placeholder) {
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fade show';
const dialog = document.createElement('div');
dialog.className = 'modal fade show d-block';
dialog.setAttribute('tabindex', '-1');
dialog.style.display = 'block';
dialog.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${title}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="text" class="form-control" value="${placeholder}" autofocus>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary">OK</button>
</div>
</div>
</div>
`;
document.body.appendChild(backdrop);
return dialog;
}
createConfirmDialog(title, message) {
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fade show';
const dialog = document.createElement('div');
dialog.className = 'modal fade show d-block';
dialog.setAttribute('tabindex', '-1');
dialog.style.display = 'block';
dialog.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-danger">
<h5 class="modal-title text-danger">${title}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>${message}</p>
<p class="text-danger small">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger">Delete</button>
</div>
</div>
</div>
`;
document.body.appendChild(backdrop);
return dialog;
}
// Old deprecated modal methods removed - all modals now use window.ModalManager
updatePasteMenuItem() {
const pasteItem = document.getElementById('pasteMenuItem');

View File

@@ -11,23 +11,41 @@ class FileTree {
this.selectedPath = null;
this.onFileSelect = null;
this.onFolderSelect = null;
// Drag and drop state
this.draggedNode = null;
this.draggedPath = null;
this.draggedIsDir = false;
// Long-press detection
this.longPressTimer = null;
this.longPressThreshold = Config.LONG_PRESS_THRESHOLD;
this.isDraggingEnabled = false;
this.mouseDownNode = null;
// Undo functionality
this.lastMoveOperation = null;
this.setupEventListeners();
this.setupUndoListener();
}
setupEventListeners() {
// Click handler for tree nodes
this.container.addEventListener('click', (e) => {
console.log('Container clicked', e.target);
const node = e.target.closest('.tree-node');
if (!node) return;
console.log('Node found', node);
const path = node.dataset.path;
const isDir = node.dataset.isdir === 'true';
// The toggle is handled inside renderNodes now
// Check if toggle was clicked (icon or toggle button)
const toggle = e.target.closest('.tree-node-toggle');
if (toggle) {
// Toggle is handled by its own click listener in renderNodes
return;
}
// Select node
if (isDir) {
this.selectFolder(path);
@@ -35,9 +53,19 @@ class FileTree {
this.selectFile(path);
}
});
// Context menu
// Context menu (only in edit mode)
this.container.addEventListener('contextmenu', (e) => {
// Check if we're in edit mode
const isEditMode = document.body.classList.contains('edit-mode');
// In view mode, disable custom context menu entirely
if (!isEditMode) {
e.preventDefault(); // Prevent default browser context menu
return; // Don't show custom context menu
}
// Edit mode: show custom context menu
const node = e.target.closest('.tree-node');
e.preventDefault();
@@ -51,8 +79,335 @@ class FileTree {
window.showContextMenu(e.clientX, e.clientY, { path: '', isDir: true });
}
});
// Drag and drop event listeners (only in edit mode)
this.setupDragAndDrop();
}
setupUndoListener() {
// Listen for Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
document.addEventListener('keydown', async (e) => {
// Check for Ctrl+Z or Cmd+Z
const isUndo = (e.ctrlKey || e.metaKey) && e.key === 'z';
if (isUndo && this.isEditMode() && this.lastMoveOperation) {
e.preventDefault();
await this.undoLastMove();
}
});
}
async undoLastMove() {
if (!this.lastMoveOperation) {
return;
}
const { sourcePath, destPath, fileName, isDirectory } = this.lastMoveOperation;
try {
// Move the item back to its original location
await this.webdavClient.move(destPath, sourcePath);
// Get the parent folder name for the notification
const sourceParent = PathUtils.getParentPath(sourcePath);
const parentName = sourceParent ? sourceParent + '/' : 'root';
// Clear the undo history
this.lastMoveOperation = null;
// Reload the tree
await this.load();
// Re-select the moved item
this.selectAndExpandPath(sourcePath);
showNotification(`Undo: Moved ${fileName} back to ${parentName}`, 'success');
} catch (error) {
console.error('Failed to undo move:', error);
showNotification('Failed to undo move: ' + error.message, 'danger');
}
}
setupDragAndDrop() {
// Dragover event on container to allow dropping on root level
this.container.addEventListener('dragover', (e) => {
if (!this.isEditMode() || !this.draggedPath) return;
const node = e.target.closest('.tree-node');
if (!node) {
// Hovering over empty space (root level)
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Highlight the entire container as a drop target
this.container.classList.add('drag-over-root');
}
});
// Dragleave event on container to remove root-level highlighting
this.container.addEventListener('dragleave', (e) => {
if (!this.isEditMode()) return;
// Only remove if we're actually leaving the container
// Check if the related target is outside the container
if (!this.container.contains(e.relatedTarget)) {
this.container.classList.remove('drag-over-root');
}
});
// Dragenter event to manage highlighting
this.container.addEventListener('dragenter', (e) => {
if (!this.isEditMode() || !this.draggedPath) return;
const node = e.target.closest('.tree-node');
if (!node) {
// Entering empty space
this.container.classList.add('drag-over-root');
} else {
// Entering a node, remove root highlighting
this.container.classList.remove('drag-over-root');
}
});
// Drop event on container for root level drops
this.container.addEventListener('drop', async (e) => {
if (!this.isEditMode()) return;
const node = e.target.closest('.tree-node');
if (!node && this.draggedPath) {
// Dropped on root level
e.preventDefault();
this.container.classList.remove('drag-over-root');
await this.handleDrop('', true);
}
});
}
isEditMode() {
return document.body.classList.contains('edit-mode');
}
setupNodeDragHandlers(nodeElement, node) {
// Dragstart - when user starts dragging
nodeElement.addEventListener('dragstart', (e) => {
this.draggedNode = nodeElement;
this.draggedPath = node.path;
this.draggedIsDir = node.isDirectory;
nodeElement.classList.add('dragging');
document.body.classList.add('dragging-active');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', node.path);
// Create a custom drag image with fixed width
const dragImage = nodeElement.cloneNode(true);
dragImage.style.position = 'absolute';
dragImage.style.top = '-9999px';
dragImage.style.left = '-9999px';
dragImage.style.width = `${Config.DRAG_PREVIEW_WIDTH}px`;
dragImage.style.maxWidth = `${Config.DRAG_PREVIEW_WIDTH}px`;
dragImage.style.opacity = Config.DRAG_PREVIEW_OPACITY;
dragImage.style.backgroundColor = 'var(--bg-secondary)';
dragImage.style.border = '1px solid var(--border-color)';
dragImage.style.borderRadius = '4px';
dragImage.style.padding = '4px 8px';
dragImage.style.whiteSpace = 'nowrap';
dragImage.style.overflow = 'hidden';
dragImage.style.textOverflow = 'ellipsis';
document.body.appendChild(dragImage);
e.dataTransfer.setDragImage(dragImage, 10, 10);
setTimeout(() => {
if (dragImage.parentNode) {
document.body.removeChild(dragImage);
}
}, 0);
});
// Dragend - when drag operation ends
nodeElement.addEventListener('dragend', () => {
nodeElement.classList.remove('dragging');
nodeElement.classList.remove('drag-ready');
document.body.classList.remove('dragging-active');
this.container.classList.remove('drag-over-root');
this.clearDragOverStates();
// Reset draggable state
nodeElement.draggable = false;
nodeElement.style.cursor = '';
this.isDraggingEnabled = false;
this.draggedNode = null;
this.draggedPath = null;
this.draggedIsDir = false;
});
// Dragover - when dragging over this node
nodeElement.addEventListener('dragover', (e) => {
if (!this.draggedPath) return;
const targetPath = node.path;
const targetIsDir = node.isDirectory;
// Only allow dropping on directories
if (!targetIsDir) {
e.dataTransfer.dropEffect = 'none';
return;
}
// Check if this is a valid drop target
if (this.isValidDropTarget(this.draggedPath, this.draggedIsDir, targetPath)) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
nodeElement.classList.add('drag-over');
} else {
e.dataTransfer.dropEffect = 'none';
}
});
// Dragleave - when drag leaves this node
nodeElement.addEventListener('dragleave', (e) => {
// Only remove if we're actually leaving the node (not entering a child)
if (e.target === nodeElement) {
nodeElement.classList.remove('drag-over');
// If leaving a node and not entering another node, might be going to root
const relatedNode = e.relatedTarget?.closest('.tree-node');
if (!relatedNode && this.container.contains(e.relatedTarget)) {
// Moving to empty space (root area)
this.container.classList.add('drag-over-root');
}
}
});
// Drop - when item is dropped on this node
nodeElement.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
nodeElement.classList.remove('drag-over');
if (!this.draggedPath) return;
const targetPath = node.path;
const targetIsDir = node.isDirectory;
if (targetIsDir && this.isValidDropTarget(this.draggedPath, this.draggedIsDir, targetPath)) {
await this.handleDrop(targetPath, targetIsDir);
}
});
}
clearDragOverStates() {
this.container.querySelectorAll('.drag-over').forEach(node => {
node.classList.remove('drag-over');
});
}
isValidDropTarget(sourcePath, sourceIsDir, targetPath) {
// Can't drop on itself
if (sourcePath === targetPath) {
return false;
}
// If dragging a directory, can't drop into its own descendants
if (sourceIsDir) {
// Check if target is a descendant of source
if (targetPath.startsWith(sourcePath + '/')) {
return false;
}
}
// Can't drop into the same parent directory
const sourceParent = PathUtils.getParentPath(sourcePath);
if (sourceParent === targetPath) {
return false;
}
return true;
}
async handleDrop(targetPath, targetIsDir) {
if (!this.draggedPath) return;
try {
const sourcePath = this.draggedPath;
const fileName = PathUtils.getFileName(sourcePath);
const isDirectory = this.draggedIsDir;
// Construct destination path
let destPath;
if (targetPath === '') {
// Dropping to root
destPath = fileName;
} else {
destPath = `${targetPath}/${fileName}`;
}
// Check if destination already exists
const destNode = this.findNode(destPath);
if (destNode) {
const overwrite = await window.ModalManager.confirm(
`A ${destNode.isDirectory ? 'folder' : 'file'} named "${fileName}" already exists in the destination. Do you want to overwrite it?`,
'Name Conflict',
true
);
if (!overwrite) {
return;
}
// Delete existing item first
await this.webdavClient.delete(destPath);
// Clear undo history since we're overwriting
this.lastMoveOperation = null;
}
// Perform the move
await this.webdavClient.move(sourcePath, destPath);
// Store undo information (only if not overwriting)
if (!destNode) {
this.lastMoveOperation = {
sourcePath: sourcePath,
destPath: destPath,
fileName: fileName,
isDirectory: isDirectory
};
}
// If the moved item was the currently selected file, update the selection
if (this.selectedPath === sourcePath) {
this.selectedPath = destPath;
// Update editor's current file path if it's the file being moved
if (!this.draggedIsDir && window.editor && window.editor.currentFile === sourcePath) {
window.editor.currentFile = destPath;
if (window.editor.filenameInput) {
window.editor.filenameInput.value = destPath;
}
}
// Notify file select callback if it's a file
if (!this.draggedIsDir && this.onFileSelect) {
this.onFileSelect({ path: destPath, isDirectory: false });
}
}
// Reload the tree
await this.load();
// Re-select the moved item
this.selectAndExpandPath(destPath);
showNotification(`Moved ${fileName} successfully`, 'success');
} catch (error) {
console.error('Failed to move item:', error);
showNotification('Failed to move item: ' + error.message, 'danger');
}
}
async load() {
try {
const items = await this.webdavClient.propfind('', 'infinity');
@@ -63,12 +418,12 @@ class FileTree {
showNotification('Failed to load files', 'error');
}
}
render() {
this.container.innerHTML = '';
this.renderNodes(this.tree, this.container, 0);
}
renderNodes(nodes, parentElement, level) {
nodes.forEach(node => {
const nodeWrapper = document.createElement('div');
@@ -78,40 +433,56 @@ class FileTree {
const nodeElement = this.createNodeElement(node, level);
nodeWrapper.appendChild(nodeElement);
// Create children container ONLY if has children
if (node.children && node.children.length > 0) {
// Create children container for directories
if (node.isDirectory) {
const childContainer = document.createElement('div');
childContainer.className = 'tree-children';
childContainer.style.display = 'none';
childContainer.dataset.parent = node.path;
childContainer.style.marginLeft = `${(level + 1) * 12}px`;
// Recursively render children
this.renderNodes(node.children, childContainer, level + 1);
// Only render children if they exist
if (node.children && node.children.length > 0) {
this.renderNodes(node.children, childContainer, level + 1);
} else {
// Empty directory - show empty state message
const emptyMessage = document.createElement('div');
emptyMessage.className = 'tree-empty-message';
emptyMessage.textContent = 'Empty folder';
childContainer.appendChild(emptyMessage);
}
nodeWrapper.appendChild(childContainer);
// Make toggle functional
// Make toggle functional for ALL directories (including empty ones)
const toggle = nodeElement.querySelector('.tree-node-toggle');
if (toggle) {
toggle.addEventListener('click', (e) => {
console.log('Toggle clicked', e.target);
const toggleHandler = (e) => {
e.stopPropagation();
const isHidden = childContainer.style.display === 'none';
console.log('Is hidden?', isHidden);
childContainer.style.display = isHidden ? 'block' : 'none';
toggle.innerHTML = isHidden ? '▼' : '▶';
toggle.style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)';
toggle.classList.toggle('expanded');
});
};
// Add click listener to toggle icon
toggle.addEventListener('click', toggleHandler);
// Also allow double-click on the node to toggle
nodeElement.addEventListener('dblclick', toggleHandler);
// Make toggle cursor pointer for all directories
toggle.style.cursor = 'pointer';
}
}
parentElement.appendChild(nodeWrapper);
});
}
// toggleFolder is no longer needed as the event listener is added in renderNodes.
selectFile(path) {
this.selectedPath = path;
this.updateSelection();
@@ -119,7 +490,7 @@ class FileTree {
this.onFileSelect({ path, isDirectory: false });
}
}
selectFolder(path) {
this.selectedPath = path;
this.updateSelection();
@@ -127,18 +498,111 @@ class FileTree {
this.onFolderSelect({ path, isDirectory: true });
}
}
/**
* Find a node by path
* @param {string} path - The path to find
* @returns {Object|null} The node or null if not found
*/
findNode(path) {
const search = (nodes, targetPath) => {
for (const node of nodes) {
if (node.path === targetPath) {
return node;
}
if (node.children && node.children.length > 0) {
const found = search(node.children, targetPath);
if (found) return found;
}
}
return null;
};
return search(this.tree, path);
}
/**
* Get all files in a directory (direct children only)
* @param {string} dirPath - The directory path
* @returns {Array} Array of file nodes
*/
getDirectoryFiles(dirPath) {
const dirNode = this.findNode(dirPath);
if (dirNode && dirNode.children) {
return dirNode.children.filter(child => !child.isDirectory);
}
return [];
}
updateSelection() {
// Remove previous selection
this.container.querySelectorAll('.tree-node').forEach(node => {
node.classList.remove('selected');
node.classList.remove('active');
});
// Add selection to current
// Add selection to current and all parent directories
if (this.selectedPath) {
// Add active class to the selected file/folder
const node = this.container.querySelector(`[data-path="${this.selectedPath}"]`);
if (node) {
node.classList.add('selected');
node.classList.add('active');
}
// Add active class to all parent directories
const parts = this.selectedPath.split('/');
let currentPath = '';
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
const parentNode = this.container.querySelector(`[data-path="${currentPath}"]`);
if (parentNode) {
parentNode.classList.add('active');
}
}
}
}
/**
* Highlight a file as active and expand all parent directories
* @param {string} path - The file path to highlight
*/
selectAndExpandPath(path) {
this.selectedPath = path;
// Expand all parent directories
this.expandParentDirectories(path);
// Update selection
this.updateSelection();
}
/**
* Expand all parent directories of a given path
* @param {string} path - The file path
*/
expandParentDirectories(path) {
// Get all parent paths
const parts = path.split('/');
let currentPath = '';
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
// Find the node with this path
const parentNode = this.container.querySelector(`[data-path="${currentPath}"]`);
if (parentNode && parentNode.dataset.isdir === 'true') {
// Find the children container
const wrapper = parentNode.closest('.tree-node-wrapper');
if (wrapper) {
const childContainer = wrapper.querySelector('.tree-children');
if (childContainer && childContainer.style.display === 'none') {
// Expand it
childContainer.style.display = 'block';
const toggle = parentNode.querySelector('.tree-node-toggle');
if (toggle) {
toggle.classList.add('expanded');
}
}
}
}
}
}
@@ -150,25 +614,111 @@ class FileTree {
nodeElement.dataset.isdir = node.isDirectory;
nodeElement.style.paddingLeft = `${level * 12}px`;
const icon = document.createElement('span');
icon.className = 'tree-node-icon';
// Enable drag and drop in edit mode with long-press detection
if (this.isEditMode()) {
// Start with draggable disabled
nodeElement.draggable = false;
this.setupNodeDragHandlers(nodeElement, node);
this.setupLongPressDetection(nodeElement, node);
}
// Create toggle/icon container
const iconContainer = document.createElement('span');
iconContainer.className = 'tree-node-icon';
if (node.isDirectory) {
icon.innerHTML = '▶'; // Collapsed by default
icon.classList.add('tree-node-toggle');
// Create toggle icon for folders
const toggle = document.createElement('i');
toggle.className = 'bi bi-chevron-right tree-node-toggle';
toggle.style.fontSize = '12px';
iconContainer.appendChild(toggle);
} else {
icon.innerHTML = '●'; // File icon
// Create file icon
const fileIcon = document.createElement('i');
fileIcon.className = 'bi bi-file-earmark-text';
fileIcon.style.fontSize = '14px';
iconContainer.appendChild(fileIcon);
}
const title = document.createElement('span');
title.className = 'tree-node-title';
title.textContent = node.name;
nodeElement.appendChild(icon);
nodeElement.appendChild(iconContainer);
nodeElement.appendChild(title);
return nodeElement;
}
setupLongPressDetection(nodeElement, node) {
// Mouse down - start long-press timer
nodeElement.addEventListener('mousedown', (e) => {
// Ignore if clicking on toggle button
if (e.target.closest('.tree-node-toggle')) {
return;
}
this.mouseDownNode = nodeElement;
// Start timer for long-press
this.longPressTimer = setTimeout(() => {
// Long-press threshold met - enable dragging
this.isDraggingEnabled = true;
nodeElement.draggable = true;
nodeElement.classList.add('drag-ready');
// Change cursor to grab
nodeElement.style.cursor = 'grab';
}, this.longPressThreshold);
});
// Mouse up - cancel long-press timer
nodeElement.addEventListener('mouseup', () => {
this.clearLongPressTimer();
});
// Mouse leave - cancel long-press timer
nodeElement.addEventListener('mouseleave', () => {
this.clearLongPressTimer();
});
// Mouse move - cancel long-press if moved too much
let startX, startY;
nodeElement.addEventListener('mousedown', (e) => {
startX = e.clientX;
startY = e.clientY;
});
nodeElement.addEventListener('mousemove', (e) => {
if (this.longPressTimer && !this.isDraggingEnabled) {
const deltaX = Math.abs(e.clientX - startX);
const deltaY = Math.abs(e.clientY - startY);
// If mouse moved more than threshold, cancel long-press
if (deltaX > Config.MOUSE_MOVE_THRESHOLD || deltaY > Config.MOUSE_MOVE_THRESHOLD) {
this.clearLongPressTimer();
}
}
});
}
clearLongPressTimer() {
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
// Reset dragging state if not currently dragging
if (!this.draggedPath && this.mouseDownNode) {
this.mouseDownNode.draggable = false;
this.mouseDownNode.classList.remove('drag-ready');
this.mouseDownNode.style.cursor = '';
this.isDraggingEnabled = false;
}
this.mouseDownNode = null;
}
formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -176,7 +726,7 @@ class FileTree {
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
}
newFile() {
this.selectedPath = null;
this.updateSelection();
@@ -200,7 +750,7 @@ class FileTree {
throw error;
}
}
async createFolder(parentPath, foldername) {
try {
const fullPath = parentPath ? `${parentPath}/${foldername}` : foldername;
@@ -214,7 +764,7 @@ class FileTree {
throw error;
}
}
async uploadFile(parentPath, file) {
try {
const fullPath = parentPath ? `${parentPath}/${file.name}` : file.name;
@@ -229,63 +779,76 @@ class FileTree {
throw error;
}
}
async downloadFile(path) {
try {
const content = await this.webdavClient.get(path);
const filename = path.split('/').pop();
this.triggerDownload(content, filename);
const filename = PathUtils.getFileName(path);
DownloadUtils.triggerDownload(content, filename);
showNotification('Downloaded', 'success');
} catch (error) {
console.error('Failed to download file:', error);
showNotification('Failed to download file', 'error');
}
}
async downloadFolder(path) {
try {
showNotification('Creating zip...', 'info');
// Get all files in folder
const items = await this.webdavClient.propfind(path, 'infinity');
const files = items.filter(item => !item.isDirectory);
// Use JSZip to create zip file
const JSZip = window.JSZip;
if (!JSZip) {
throw new Error('JSZip not loaded');
}
const zip = new JSZip();
const folder = zip.folder(path.split('/').pop() || 'download');
const folder = zip.folder(PathUtils.getFileName(path) || 'download');
// Add all files to zip
for (const file of files) {
const content = await this.webdavClient.get(file.path);
const relativePath = file.path.replace(path + '/', '');
folder.file(relativePath, content);
}
// Generate zip
const zipBlob = await zip.generateAsync({ type: 'blob' });
const zipFilename = `${path.split('/').pop() || 'download'}.zip`;
this.triggerDownload(zipBlob, zipFilename);
const zipFilename = `${PathUtils.getFileName(path) || 'download'}.zip`;
DownloadUtils.triggerDownload(zipBlob, zipFilename);
showNotification('Downloaded', 'success');
} catch (error) {
console.error('Failed to download folder:', error);
showNotification('Failed to download folder', 'error');
}
}
triggerDownload(content, filename) {
const blob = content instanceof Blob ? content : new Blob([content]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// triggerDownload method moved to DownloadUtils in utils.js
/**
* Get the first markdown file in the tree
* Returns the path of the first .md file found, or null if none exist
*/
getFirstMarkdownFile() {
const findFirstFile = (nodes) => {
for (const node of nodes) {
// If it's a file and ends with .md, return it
if (!node.isDirectory && node.path.endsWith('.md')) {
return node.path;
}
// If it's a directory with children, search recursively
if (node.isDirectory && node.children && node.children.length > 0) {
const found = findFirstFile(node.children);
if (found) return found;
}
}
return null;
};
return findFirstFile(this.tree);
}
}

37
static/js/file-upload.js Normal file
View File

@@ -0,0 +1,37 @@
/**
* File Upload Module
* Handles file upload dialog for uploading files to the file tree
*/
/**
* Show file upload dialog
* @param {string} targetPath - The target directory path
* @param {Function} onUpload - Callback function to handle file upload
*/
function showFileUploadDialog(targetPath, onUpload) {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
if (files.length === 0) return;
for (const file of files) {
try {
await onUpload(targetPath, file);
} catch (error) {
Logger.error('Upload failed:', error);
if (window.showNotification) {
window.showNotification(`Failed to upload ${file.name}`, 'error');
}
}
}
});
input.click();
}
// Make function globally available
window.showFileUploadDialog = showFileUploadDialog;

174
static/js/logger.js Normal file
View File

@@ -0,0 +1,174 @@
/**
* Logger Module
* Provides structured logging with different levels
* Can be configured to show/hide different log levels
*/
class Logger {
/**
* Log levels
*/
static LEVELS = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
NONE: 4
};
/**
* Current log level
* Set to DEBUG by default, can be changed via setLevel()
*/
static currentLevel = Logger.LEVELS.DEBUG;
/**
* Enable/disable logging
*/
static enabled = true;
/**
* Set the minimum log level
* @param {number} level - One of Logger.LEVELS
*/
static setLevel(level) {
if (typeof level === 'number' && level >= 0 && level <= 4) {
Logger.currentLevel = level;
}
}
/**
* Enable or disable logging
* @param {boolean} enabled - Whether to enable logging
*/
static setEnabled(enabled) {
Logger.enabled = enabled;
}
/**
* Log a debug message
* @param {string} message - The message to log
* @param {...any} args - Additional arguments to log
*/
static debug(message, ...args) {
if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.DEBUG) {
return;
}
console.log(`[DEBUG] ${message}`, ...args);
}
/**
* Log an info message
* @param {string} message - The message to log
* @param {...any} args - Additional arguments to log
*/
static info(message, ...args) {
if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.INFO) {
return;
}
console.info(`[INFO] ${message}`, ...args);
}
/**
* Log a warning message
* @param {string} message - The message to log
* @param {...any} args - Additional arguments to log
*/
static warn(message, ...args) {
if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.WARN) {
return;
}
console.warn(`[WARN] ${message}`, ...args);
}
/**
* Log an error message
* @param {string} message - The message to log
* @param {...any} args - Additional arguments to log
*/
static error(message, ...args) {
if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.ERROR) {
return;
}
console.error(`[ERROR] ${message}`, ...args);
}
/**
* Log a message with a custom prefix
* @param {string} prefix - The prefix to use
* @param {string} message - The message to log
* @param {...any} args - Additional arguments to log
*/
static log(prefix, message, ...args) {
if (!Logger.enabled) {
return;
}
console.log(`[${prefix}] ${message}`, ...args);
}
/**
* Group related log messages
* @param {string} label - The group label
*/
static group(label) {
if (!Logger.enabled) {
return;
}
console.group(label);
}
/**
* End a log group
*/
static groupEnd() {
if (!Logger.enabled) {
return;
}
console.groupEnd();
}
/**
* Log a table (useful for arrays of objects)
* @param {any} data - The data to display as a table
*/
static table(data) {
if (!Logger.enabled) {
return;
}
console.table(data);
}
/**
* Start a timer
* @param {string} label - The timer label
*/
static time(label) {
if (!Logger.enabled) {
return;
}
console.time(label);
}
/**
* End a timer and log the elapsed time
* @param {string} label - The timer label
*/
static timeEnd(label) {
if (!Logger.enabled) {
return;
}
console.timeEnd(label);
}
}
// Make Logger globally available
window.Logger = Logger;
// Set default log level based on environment
// In production, you might want to set this to WARN or ERROR
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
Logger.setLevel(Logger.LEVELS.DEBUG);
} else {
Logger.setLevel(Logger.LEVELS.INFO);
}

View File

@@ -10,7 +10,7 @@ class MacroProcessor {
this.includeStack = []; // Track includes to detect cycles
this.registerDefaultPlugins();
}
/**
* Register a macro plugin
* Plugin must implement: { canHandle(actor, method), process(macro, webdavClient) }
@@ -19,27 +19,23 @@ class MacroProcessor {
const key = `${actor}.${method}`;
this.plugins.set(key, plugin);
}
/**
* Process all macros in content
* Returns { success: boolean, content: string, errors: [] }
*/
async processMacros(content) {
console.log('MacroProcessor: Starting macro processing for content:', content);
const macros = MacroParser.extractMacros(content);
console.log('MacroProcessor: Extracted macros:', macros);
const errors = [];
let processedContent = content;
// Process macros in reverse order to preserve positions
for (let i = macros.length - 1; i >= 0; i--) {
const macro = macros[i];
console.log('MacroProcessor: Processing macro:', macro);
try {
const result = await this.processMacro(macro);
console.log('MacroProcessor: Macro processing result:', result);
if (result.success) {
// Replace macro with result
processedContent =
@@ -51,7 +47,7 @@ class MacroProcessor {
macro: macro.fullMatch,
error: result.error
});
// Replace with error message
const errorMsg = `\n\n⚠️ **Macro Error**: ${result.error}\n\n`;
processedContent =
@@ -64,7 +60,7 @@ class MacroProcessor {
macro: macro.fullMatch,
error: error.message
});
const errorMsg = `\n\n⚠️ **Macro Error**: ${error.message}\n\n`;
processedContent =
processedContent.substring(0, macro.start) +
@@ -72,15 +68,14 @@ class MacroProcessor {
processedContent.substring(macro.end);
}
}
console.log('MacroProcessor: Final processed content:', processedContent);
return {
success: errors.length === 0,
content: processedContent,
errors
};
}
/**
* Process single macro
*/
@@ -98,20 +93,20 @@ class MacroProcessor {
};
}
}
if (!plugin) {
return {
success: false,
error: `Unknown macro: !!${key}`
};
}
// Validate macro
const validation = MacroParser.validateMacro(macro);
if (!validation.valid) {
return { success: false, error: validation.error };
}
// Execute plugin
try {
return await plugin.process(macro, this.webdavClient);
@@ -122,7 +117,7 @@ class MacroProcessor {
};
}
}
/**
* Register default plugins
*/
@@ -131,14 +126,14 @@ class MacroProcessor {
this.registerPlugin('core', 'include', {
process: async (macro, webdavClient) => {
const path = macro.params.path || macro.params[''];
if (!path) {
return {
success: false,
error: 'include macro requires "path" parameter'
};
}
try {
// Add to include stack
this.includeStack.push(path);

View File

@@ -0,0 +1,77 @@
/**
* Notification Service
* Provides a standardized way to show toast notifications
* Wraps the showNotification function from ui-utils.js
*/
class NotificationService {
/**
* Show a success notification
* @param {string} message - The message to display
*/
static success(message) {
if (window.showNotification) {
window.showNotification(message, Config.NOTIFICATION_TYPES.SUCCESS);
} else {
Logger.warn('showNotification not available, falling back to console');
console.log(`${message}`);
}
}
/**
* Show an error notification
* @param {string} message - The message to display
*/
static error(message) {
if (window.showNotification) {
window.showNotification(message, Config.NOTIFICATION_TYPES.ERROR);
} else {
Logger.warn('showNotification not available, falling back to console');
console.error(`${message}`);
}
}
/**
* Show a warning notification
* @param {string} message - The message to display
*/
static warning(message) {
if (window.showNotification) {
window.showNotification(message, Config.NOTIFICATION_TYPES.WARNING);
} else {
Logger.warn('showNotification not available, falling back to console');
console.warn(`⚠️ ${message}`);
}
}
/**
* Show an info notification
* @param {string} message - The message to display
*/
static info(message) {
if (window.showNotification) {
window.showNotification(message, Config.NOTIFICATION_TYPES.INFO);
} else {
Logger.warn('showNotification not available, falling back to console');
console.info(` ${message}`);
}
}
/**
* Show a notification with a custom type
* @param {string} message - The message to display
* @param {string} type - The notification type (success, danger, warning, primary, etc.)
*/
static show(message, type = 'primary') {
if (window.showNotification) {
window.showNotification(message, type);
} else {
Logger.warn('showNotification not available, falling back to console');
console.log(`[${type.toUpperCase()}] ${message}`);
}
}
}
// Make NotificationService globally available
window.NotificationService = NotificationService;

View File

@@ -1,270 +1,60 @@
/**
* UI Utilities Module
* Toast notifications, context menu, dark mode, file upload dialog
* Toast notifications (kept for backward compatibility)
*
* Other utilities have been moved to separate modules:
* - Context menu: context-menu.js
* - File upload: file-upload.js
* - Dark mode: dark-mode.js
* - Collection selector: collection-selector.js
* - Editor drop handler: editor-drop-handler.js
*/
/**
* Show toast notification
* @param {string} message - The message to display
* @param {string} type - The notification type (info, success, error, warning, danger, primary)
*/
function showNotification(message, type = 'info') {
const container = document.getElementById('toastContainer') || createToastContainer();
const toast = document.createElement('div');
const bgClass = type === 'error' ? 'danger' : type === 'success' ? 'success' : type === 'warning' ? 'warning' : 'primary';
toast.className = `toast align-items-center text-white bg-${bgClass} border-0`;
toast.setAttribute('role', 'alert');
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
container.appendChild(toast);
const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
const bsToast = new bootstrap.Toast(toast, { delay: Config.TOAST_DURATION });
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => {
toast.remove();
});
}
/**
* Create the toast container if it doesn't exist
* @returns {HTMLElement} The toast container element
*/
function createToastContainer() {
const container = document.createElement('div');
container.id = 'toastContainer';
container.className = 'toast-container position-fixed top-0 end-0 p-3';
container.style.zIndex = '9999';
container.style.zIndex = Config.TOAST_Z_INDEX;
document.body.appendChild(container);
return container;
}
/**
* Enhanced Context Menu
*/
function showContextMenu(x, y, target) {
const menu = document.getElementById('contextMenu');
if (!menu) return;
// Store target data
menu.dataset.targetPath = target.path;
menu.dataset.targetIsDir = target.isDir;
// Show/hide menu items based on target type
const items = {
'new-file': target.isDir,
'new-folder': target.isDir,
'upload': target.isDir,
'download': true,
'paste': target.isDir && window.fileTreeActions?.clipboard,
'open': !target.isDir
};
Object.entries(items).forEach(([action, show]) => {
const item = menu.querySelector(`[data-action="${action}"]`);
if (item) {
item.style.display = show ? 'flex' : 'none';
}
});
// Position menu
menu.style.display = 'block';
menu.style.left = x + 'px';
menu.style.top = y + 'px';
// Adjust if off-screen
setTimeout(() => {
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menu.style.left = (window.innerWidth - rect.width - 10) + 'px';
}
if (rect.bottom > window.innerHeight) {
menu.style.top = (window.innerHeight - rect.height - 10) + 'px';
}
}, 0);
}
function hideContextMenu() {
const menu = document.getElementById('contextMenu');
if (menu) {
menu.style.display = 'none';
}
}
// Combined click handler for context menu and outside clicks
document.addEventListener('click', async (e) => {
const menuItem = e.target.closest('.context-menu-item');
if (menuItem) {
// Handle context menu item click
const action = menuItem.dataset.action;
const menu = document.getElementById('contextMenu');
const targetPath = menu.dataset.targetPath;
const isDir = menu.dataset.targetIsDir === 'true';
hideContextMenu();
if (window.fileTreeActions) {
await window.fileTreeActions.execute(action, targetPath, isDir);
}
} else if (!e.target.closest('#contextMenu') && !e.target.closest('.tree-node')) {
// Hide on outside click
hideContextMenu();
}
});
/**
* File Upload Dialog
*/
function showFileUploadDialog(targetPath, onUpload) {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
if (files.length === 0) return;
for (const file of files) {
try {
await onUpload(targetPath, file);
} catch (error) {
console.error('Upload failed:', error);
}
}
});
input.click();
}
/**
* Dark Mode Manager
*/
class DarkMode {
constructor() {
this.isDark = localStorage.getItem('darkMode') === 'true';
this.apply();
}
toggle() {
this.isDark = !this.isDark;
localStorage.setItem('darkMode', this.isDark);
this.apply();
}
apply() {
if (this.isDark) {
document.body.classList.add('dark-mode');
const btn = document.getElementById('darkModeBtn');
if (btn) btn.textContent = '☀️';
// Update mermaid theme
if (window.mermaid) {
mermaid.initialize({ theme: 'dark' });
}
} else {
document.body.classList.remove('dark-mode');
const btn = document.getElementById('darkModeBtn');
if (btn) btn.textContent = '🌙';
// Update mermaid theme
if (window.mermaid) {
mermaid.initialize({ theme: 'default' });
}
}
}
}
/**
* Collection Selector
*/
class CollectionSelector {
constructor(selectId, webdavClient) {
this.select = document.getElementById(selectId);
this.webdavClient = webdavClient;
this.onChange = null;
}
async load() {
try {
const collections = await this.webdavClient.getCollections();
this.select.innerHTML = '';
collections.forEach(collection => {
const option = document.createElement('option');
option.value = collection;
option.textContent = collection;
this.select.appendChild(option);
});
// Select first collection
if (collections.length > 0) {
this.select.value = collections[0];
this.webdavClient.setCollection(collections[0]);
if (this.onChange) {
this.onChange(collections[0]);
}
}
// Add change listener
this.select.addEventListener('change', () => {
const collection = this.select.value;
this.webdavClient.setCollection(collection);
if (this.onChange) {
this.onChange(collection);
}
});
} catch (error) {
console.error('Failed to load collections:', error);
showNotification('Failed to load collections', 'error');
}
}
}
/**
* Editor Drop Handler
* Handles file drops into the editor
*/
class EditorDropHandler {
constructor(editorElement, onFileDrop) {
this.editorElement = editorElement;
this.onFileDrop = onFileDrop;
this.setupHandlers();
}
setupHandlers() {
this.editorElement.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
this.editorElement.classList.add('drag-over');
});
this.editorElement.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
this.editorElement.classList.remove('drag-over');
});
this.editorElement.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
this.editorElement.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
for (const file of files) {
try {
if (this.onFileDrop) {
await this.onFileDrop(file);
}
} catch (error) {
console.error('Drop failed:', error);
showNotification(`Failed to upload ${file.name}`, 'error');
}
}
});
}
}
// All other UI utilities have been moved to separate modules
// See the module list at the top of this file
// Make showNotification globally available
window.showNotification = showNotification;

355
static/js/utils.js Normal file
View File

@@ -0,0 +1,355 @@
/**
* Utilities Module
* Common utility functions used throughout the application
*/
/**
* Path Utilities
* Helper functions for path manipulation
*/
const PathUtils = {
/**
* Get the filename from a path
* @param {string} path - The file path
* @returns {string} The filename
* @example PathUtils.getFileName('folder/subfolder/file.md') // 'file.md'
*/
getFileName(path) {
if (!path) return '';
return path.split('/').pop();
},
/**
* Get the parent directory path
* @param {string} path - The file path
* @returns {string} The parent directory path
* @example PathUtils.getParentPath('folder/subfolder/file.md') // 'folder/subfolder'
*/
getParentPath(path) {
if (!path) return '';
const lastSlash = path.lastIndexOf('/');
return lastSlash === -1 ? '' : path.substring(0, lastSlash);
},
/**
* Normalize a path by removing duplicate slashes
* @param {string} path - The path to normalize
* @returns {string} The normalized path
* @example PathUtils.normalizePath('folder//subfolder///file.md') // 'folder/subfolder/file.md'
*/
normalizePath(path) {
if (!path) return '';
return path.replace(/\/+/g, '/');
},
/**
* Join multiple path segments
* @param {...string} paths - Path segments to join
* @returns {string} The joined path
* @example PathUtils.joinPaths('folder', 'subfolder', 'file.md') // 'folder/subfolder/file.md'
*/
joinPaths(...paths) {
return PathUtils.normalizePath(paths.filter(p => p).join('/'));
},
/**
* Get the file extension
* @param {string} path - The file path
* @returns {string} The file extension (without dot)
* @example PathUtils.getExtension('file.md') // 'md'
*/
getExtension(path) {
if (!path) return '';
const fileName = PathUtils.getFileName(path);
const lastDot = fileName.lastIndexOf('.');
return lastDot === -1 ? '' : fileName.substring(lastDot + 1);
},
/**
* Check if a path is a descendant of another path
* @param {string} path - The path to check
* @param {string} ancestorPath - The potential ancestor path
* @returns {boolean} True if path is a descendant of ancestorPath
* @example PathUtils.isDescendant('folder/subfolder/file.md', 'folder') // true
*/
isDescendant(path, ancestorPath) {
if (!path || !ancestorPath) return false;
return path.startsWith(ancestorPath + '/');
}
};
/**
* DOM Utilities
* Helper functions for DOM manipulation
*/
const DOMUtils = {
/**
* Create an element with optional class and attributes
* @param {string} tag - The HTML tag name
* @param {string} [className] - Optional class name(s)
* @param {Object} [attributes] - Optional attributes object
* @returns {HTMLElement} The created element
*/
createElement(tag, className = '', attributes = {}) {
const element = document.createElement(tag);
if (className) {
element.className = className;
}
Object.entries(attributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
return element;
},
/**
* Remove all children from an element
* @param {HTMLElement} element - The element to clear
*/
removeAllChildren(element) {
while (element.firstChild) {
element.removeChild(element.firstChild);
}
},
/**
* Toggle a class on an element
* @param {HTMLElement} element - The element
* @param {string} className - The class name
* @param {boolean} [force] - Optional force add/remove
*/
toggleClass(element, className, force) {
if (force !== undefined) {
element.classList.toggle(className, force);
} else {
element.classList.toggle(className);
}
},
/**
* Query selector with error handling
* @param {string} selector - The CSS selector
* @param {HTMLElement} [parent] - Optional parent element
* @returns {HTMLElement|null} The found element or null
*/
querySelector(selector, parent = document) {
try {
return parent.querySelector(selector);
} catch (error) {
Logger.error(`Invalid selector: ${selector}`, error);
return null;
}
},
/**
* Query selector all with error handling
* @param {string} selector - The CSS selector
* @param {HTMLElement} [parent] - Optional parent element
* @returns {NodeList|Array} The found elements or empty array
*/
querySelectorAll(selector, parent = document) {
try {
return parent.querySelectorAll(selector);
} catch (error) {
Logger.error(`Invalid selector: ${selector}`, error);
return [];
}
}
};
/**
* Timing Utilities
* Helper functions for timing and throttling
*/
const TimingUtils = {
/**
* Debounce a function
* @param {Function} func - The function to debounce
* @param {number} wait - The wait time in milliseconds
* @returns {Function} The debounced function
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
/**
* Throttle a function
* @param {Function} func - The function to throttle
* @param {number} wait - The wait time in milliseconds
* @returns {Function} The throttled function
*/
throttle(func, wait) {
let inThrottle;
return function executedFunction(...args) {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => inThrottle = false, wait);
}
};
},
/**
* Delay execution
* @param {number} ms - Milliseconds to delay
* @returns {Promise} Promise that resolves after delay
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
};
/**
* Download Utilities
* Helper functions for file downloads
*/
const DownloadUtils = {
/**
* Trigger a download in the browser
* @param {string|Blob} content - The content to download
* @param {string} filename - The filename for the download
*/
triggerDownload(content, filename) {
const blob = content instanceof Blob ? content : new Blob([content]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
/**
* Download content as a blob
* @param {Blob} blob - The blob to download
* @param {string} filename - The filename for the download
*/
downloadAsBlob(blob, filename) {
DownloadUtils.triggerDownload(blob, filename);
}
};
/**
* Validation Utilities
* Helper functions for input validation
*/
const ValidationUtils = {
/**
* Validate and sanitize a filename
* @param {string} name - The filename to validate
* @param {boolean} [isFolder=false] - Whether this is a folder name
* @returns {Object} Validation result with {valid, sanitized, message}
*/
validateFileName(name, isFolder = false) {
const type = isFolder ? 'folder' : 'file';
if (!name || name.trim().length === 0) {
return { valid: false, sanitized: '', message: `${type} name cannot be empty` };
}
// Check for invalid characters using pattern from Config
const validPattern = Config.FILENAME_PATTERN;
if (!validPattern.test(name)) {
const sanitized = ValidationUtils.sanitizeFileName(name);
return {
valid: false,
sanitized,
message: `Invalid characters in ${type} name. Only lowercase letters, numbers, and underscores allowed.\n\nSuggestion: "${sanitized}"`
};
}
return { valid: true, sanitized: name, message: '' };
},
/**
* Sanitize a filename by removing/replacing invalid characters
* @param {string} name - The filename to sanitize
* @returns {string} The sanitized filename
*/
sanitizeFileName(name) {
return name
.toLowerCase()
.replace(Config.FILENAME_INVALID_CHARS, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
},
/**
* Check if a string is empty or whitespace
* @param {string} str - The string to check
* @returns {boolean} True if empty or whitespace
*/
isEmpty(str) {
return !str || str.trim().length === 0;
},
/**
* Check if a value is a valid email
* @param {string} email - The email to validate
* @returns {boolean} True if valid email
*/
isValidEmail(email) {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailPattern.test(email);
}
};
/**
* String Utilities
* Helper functions for string manipulation
*/
const StringUtils = {
/**
* Truncate a string to a maximum length
* @param {string} str - The string to truncate
* @param {number} maxLength - Maximum length
* @param {string} [suffix='...'] - Suffix to add if truncated
* @returns {string} The truncated string
*/
truncate(str, maxLength, suffix = '...') {
if (!str || str.length <= maxLength) return str;
return str.substring(0, maxLength - suffix.length) + suffix;
},
/**
* Capitalize the first letter of a string
* @param {string} str - The string to capitalize
* @returns {string} The capitalized string
*/
capitalize(str) {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
},
/**
* Convert a string to kebab-case
* @param {string} str - The string to convert
* @returns {string} The kebab-case string
*/
toKebabCase(str) {
return str
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/[\s_]+/g, '-')
.toLowerCase();
}
};
// Make utilities globally available
window.PathUtils = PathUtils;
window.DOMUtils = DOMUtils;
window.TimingUtils = TimingUtils;
window.DownloadUtils = DownloadUtils;
window.ValidationUtils = ValidationUtils;
window.StringUtils = StringUtils;