Compare commits

...

10 Commits

Author SHA1 Message Date
8750e0af39 ... 2025-10-26 10:52:27 +04:00
d48e25ce90 ... 2025-10-26 10:27:48 +04:00
11038e0bcd ... 2025-10-26 09:19:27 +04:00
cae90ec3dc ... 2025-10-26 09:15:51 +04:00
b9349425d7 ... 2025-10-26 09:07:21 +04:00
cdc753e72d ... 2025-10-26 08:51:22 +04:00
98a529a3cc ... 2025-10-26 08:42:43 +04:00
5c9e07eee0 ... 2025-10-26 08:14:23 +04:00
12b4685457 ... 2025-10-26 07:49:26 +04:00
3fc8329303 ... 2025-10-26 07:28:22 +04:00
26 changed files with 1802 additions and 476 deletions

View File

@@ -1,58 +0,0 @@
# Welcome to Markdown Editor
This is a **WebDAV-based** markdown editor with modular architecture.
```mermaid
%%{init: {'theme':'dark'}}%%
graph TD
%% User side
H1[Human A] --> PA1[Personal Agent A]
H2[Human B] --> PA2[Personal Agent B]
%% Local mail nodes
PA1 --> M1[MyMail Node A]
PA2 --> M2[MyMail Node B]
%% Proxy coordination layer
M1 --> Proxy1A[Proxy Agent L1]
Proxy1A --> Proxy2A[Proxy Agent L2]
Proxy2A --> Proxy2B[Proxy Agent L2]
Proxy2B --> Proxy1B[Proxy Agent L1]
Proxy1B --> M2
%% Blockchain anchoring
M1 --> Chain[Dynamic Blockchain]
M2 --> Chain
```
## Features
- ✅ Standards-compliant WebDAV backend
- ✅ Multiple document collections
- ✅ Modular JavaScript/CSS
- ✅ Live preview
- ✅ Syntax highlighting
- ✅ Mermaid diagrams
- ✅ Dark mode
## WebDAV Methods
This editor uses standard WebDAV methods:
- `PROPFIND` - List files
- `GET` - Read files
- `PUT` - Create/update files
- `DELETE` - Delete files
- `COPY` - Copy files
- `MOVE` - Move/rename files
- `MKCOL` - Create directories
## Try It Out
1. Create a new file
2. Edit markdown
3. See live preview
4. Save with WebDAV PUT
Enjoy!

10
collections/notes/test.md Normal file
View File

@@ -0,0 +1,10 @@
# test
- 1
- 2
[2025 SeaweedFS Intro Slides.pdf](/notes/2025 SeaweedFS Intro Slides.pdf)

View File

@@ -1,4 +1,9 @@
test
# test
- 1
- 2
!!include path:test2.md

View File

@@ -0,0 +1,12 @@
## test2
- something
- another thing

View File

@@ -16,7 +16,7 @@ collections:
# Server settings
server:
host: "0.0.0.0"
host: "localhost"
port: 8004
# WebDAV settings

View File

@@ -19,6 +19,8 @@ class MarkdownEditorApp:
"""Main application that wraps WsgiDAV and adds custom endpoints"""
def __init__(self, config_path="config.yaml"):
self.root_path = Path(__file__).parent.resolve()
os.chdir(self.root_path)
self.config = self.load_config(config_path)
self.collections = self.config.get('collections', {})
self.setup_collections()
@@ -51,7 +53,7 @@ class MarkdownEditorApp:
config = {
'host': self.config['server']['host'],
'port': self.config['server']['port'],
'port': int(os.environ.get('PORT', self.config['server']['port'])),
'provider_mapping': provider_mapping,
'verbose': self.config['webdav'].get('verbose', 1),
'logging': {
@@ -73,20 +75,30 @@ class MarkdownEditorApp:
path = environ.get('PATH_INFO', '')
method = environ.get('REQUEST_METHOD', '')
# Handle collection list endpoint
if path == '/fs/' and method == 'GET':
return self.handle_collections_list(environ, start_response)
# Handle static files
if path.startswith('/static/'):
return self.handle_static(environ, start_response)
# Handle root - serve index.html
# Root and index.html
if path == '/' or path == '/index.html':
return self.handle_index(environ, start_response)
# All other requests go to WebDAV
return self.webdav_app(environ, start_response)
# Static files
if path.startswith('/static/'):
return self.handle_static(environ, start_response)
# Health check
if path == '/health' and method == 'GET':
start_response('200 OK', [('Content-Type', 'text/plain')])
return [b'OK']
# API for collections
if path == '/fs/' and method == 'GET':
return self.handle_collections_list(environ, start_response)
# All other /fs/ requests go to WebDAV
if path.startswith('/fs/'):
return self.webdav_app(environ, start_response)
# Fallback for anything else (shouldn't happen with correct linking)
start_response('404 Not Found', [('Content-Type', 'text/plain')])
return [b'Not Found']
def handle_collections_list(self, environ, start_response):
"""Return list of available collections"""
@@ -104,9 +116,9 @@ class MarkdownEditorApp:
def handle_static(self, environ, start_response):
"""Serve static files"""
path = environ.get('PATH_INFO', '')[1:] # Remove leading /
file_path = Path(path)
file_path = self.root_path / path
if not file_path.exists() or not file_path.is_file():
if not file_path.is_file():
start_response('404 Not Found', [('Content-Type', 'text/plain')])
return [b'File not found']
@@ -139,9 +151,9 @@ class MarkdownEditorApp:
def handle_index(self, environ, start_response):
"""Serve index.html"""
index_path = Path('templates/index.html')
index_path = self.root_path / 'templates' / 'index.html'
if not index_path.exists():
if not index_path.is_file():
start_response('404 Not Found', [('Content-Type', 'text/plain')])
return [b'index.html not found']
@@ -167,7 +179,7 @@ def main():
# Get server config
host = app.config['server']['host']
port = app.config['server']['port']
port = int(os.environ.get('PORT', app.config['server']['port']))
print(f"\nServer starting on http://{host}:{port}")
print(f"\nAvailable collections:")
@@ -187,6 +199,7 @@ def main():
try:
server.start()
server.wait()
except KeyboardInterrupt:
print("\n\nShutting down...")
server.stop()

View File

@@ -1,5 +1,8 @@
#!/bin/bash
set -e
# Change to the script's directory to ensure relative paths work
cd "$(dirname "$0")"
echo "=============================================="
echo "Markdown Editor v3.0 - WebDAV Server"
echo "=============================================="
@@ -16,5 +19,8 @@ echo "Activating virtual environment..."
source .venv/bin/activate
echo "Installing dependencies..."
uv pip install wsgidav cheroot pyyaml
PORT=8004
echo "Checking for process on port $PORT..."
lsof -ti:$PORT | xargs -r kill -9
echo "Starting WebDAV server..."
python server_webdav.py

View File

@@ -158,3 +158,51 @@ body.dark-mode .context-menu {
}
}
/* Modal Dialogs */
.modal {
z-index: 10000;
}
.modal-backdrop {
z-index: 9999;
background-color: rgba(0, 0, 0, 0.5);
}
body.dark-mode .modal-content {
background-color: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
body.dark-mode .modal-header {
border-bottom-color: var(--border-color);
background-color: var(--bg-tertiary);
}
body.dark-mode .modal-footer {
border-top-color: var(--border-color);
background-color: var(--bg-tertiary);
}
.modal-header.border-danger {
border-bottom: 2px solid var(--danger-color) !important;
}
.modal-open {
overflow: hidden;
}
/* Input in modal */
.modal-body input.form-control {
background-color: var(--bg-primary);
color: var(--text-primary);
border-color: var(--border-color);
}
.modal-body input.form-control:focus {
background-color: var(--bg-primary);
color: var(--text-primary);
border-color: var(--link-color);
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}

View File

@@ -1,7 +1,12 @@
/* File tree styles */
/* Bootstrap-styled File Tree */
.file-tree {
font-size: 14px;
font-size: 13px;
user-select: none;
padding: 8px 0;
}
.tree-node-wrapper {
margin: 0;
}
.tree-node {
@@ -9,11 +14,14 @@
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
border-radius: 4px;
margin: 2px 0;
margin: 1px 4px;
color: var(--text-primary);
transition: background-color 0.15s ease;
transition: all 0.15s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-node:hover {
@@ -21,20 +29,49 @@
}
.tree-node.active {
background-color: #0969da;
background-color: var(--link-color);
color: white;
font-weight: 500;
}
.tree-node.active:hover {
background-color: var(--link-color);
filter: brightness(1.1);
}
/* Toggle arrow */
.tree-node-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 10px;
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.2s ease;
}
.tree-node-toggle.expanded {
transform: rotate(90deg);
}
/* Icon styling */
.tree-node-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-secondary);
}
.tree-node.active .tree-node-icon {
color: white;
}
body.dark-mode .tree-node.active {
background-color: #1f6feb;
}
.tree-node-icon {
width: 16px;
text-align: center;
flex-shrink: 0;
}
/* Content wrapper */
.tree-node-content {
display: flex;
align-items: center;
@@ -48,41 +85,94 @@ body.dark-mode .tree-node.active {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.tree-node-size {
font-size: 11px;
.file-size-badge {
font-size: 10px;
color: var(--text-secondary);
margin-left: auto;
flex-shrink: 0;
padding: 2px 6px;
background-color: var(--bg-tertiary);
border-radius: 3px;
}
/* Children container */
.tree-children {
margin-left: 16px;
margin-left: 8px;
border-left: 1px solid var(--border-light);
padding-left: 4px;
max-height: 100%;
overflow: visible;
}
.tree-children.collapsed {
display: none;
}
.tree-node {
cursor: move;
}
.tree-node.dragging {
opacity: 0.5;
background-color: rgba(13, 110, 253, 0.1);
}
.tree-node.drag-over {
background-color: var(--info-color);
color: white;
}
/* Collection selector */
.collection-selector {
margin-bottom: 10px;
padding: 8px;
background-color: var(--bg-tertiary);
background-color: rgba(13, 110, 253, 0.2) !important;
border: 1px dashed var(--link-color);
border-radius: 4px;
}
.collection-selector select {
width: 100%;
padding: 6px;
/* Collection selector - Bootstrap styled */
.collection-selector {
margin: 12px 8px;
padding: 8px 12px;
background-color: var(--bg-tertiary);
border-radius: 6px;
border: 1px solid var(--border-color);
}
.collection-selector .form-label {
margin-bottom: 6px;
font-weight: 500;
font-size: 12px;
color: var(--text-secondary);
}
.collection-selector .form-select-sm {
padding: 4px 8px;
font-size: 13px;
background-color: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.collection-selector .form-select-sm:focus {
border-color: var(--link-color);
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
/* Dark mode adjustments */
body.dark-mode .tree-node:hover {
background-color: var(--bg-tertiary);
}
body.dark-mode .tree-node.active {
background-color: var(--link-color);
}
body.dark-mode .tree-children {
border-left-color: var(--border-color);
}
/* Scrollbar in sidebar */
.sidebar::-webkit-scrollbar-thumb {
background-color: var(--border-color);
}
.sidebar::-webkit-scrollbar-thumb:hover {
background-color: var(--text-secondary);
}

View File

@@ -9,31 +9,96 @@ html, body {
transition: background-color 0.3s ease, color 0.3s ease;
}
.container-fluid {
height: calc(100% - 56px);
/* Column Resizer */
.column-resizer {
width: 1px;
background-color: var(--border-color);
cursor: col-resize;
transition: background-color 0.2s ease, width 0.2s ease, box-shadow 0.2s ease;
user-select: none;
flex-shrink: 0;
padding: 0 3px; /* Add invisible padding for easier grab */
margin: 0 -3px; /* Compensate for padding */
}
.column-resizer:hover {
background-color: var(--link-color);
width: 1px;
box-shadow: 0 0 6px rgba(13, 110, 253, 0.3); /* Visual feedback instead of width change */
}
.column-resizer.dragging {
background-color: var(--link-color);
box-shadow: 0 0 8px rgba(13, 110, 253, 0.5);
}
.column-resizer.dragging {
background-color: var(--link-color);
}
/* Adjust container for flex layout */
.container-fluid {
display: flex;
flex-direction: row;
height: calc(100% - 56px);
padding: 0;
}
.row {
width: 100%;
display: flex;
flex-direction: row;
margin: 0;
height: 100%;
}
#sidebarPane {
flex: 0 0 20%;
min-width: 150px;
max-width: 40%;
padding: 0;
}
#editorPane {
flex: 1 1 40%;
min-width: 250px;
max-width: 70%;
padding: 0;
}
#previewPane {
flex: 1 1 40%;
min-width: 250px;
max-width: 70%;
padding: 0;
}
/* Sidebar - improved */
.sidebar {
background-color: var(--bg-secondary);
border-right: 1px solid var(--border-color);
overflow-y: auto;
overflow-x: hidden;
height: 100%;
transition: background-color 0.3s ease;
}
.editor-pane {
background-color: var(--bg-primary);
height: 100%;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
}
.preview-pane {
background-color: var(--bg-primary);
height: 100%;
.sidebar h6 {
margin: 12px 8px 6px;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
#fileTree {
flex: 1;
overflow-y: auto;
padding: 20px;
overflow-x: hidden;
padding: 4px 0;
}
/* Navbar */
@@ -67,3 +132,31 @@ html, body {
background: var(--text-secondary);
}
/* Preview Pane Styling */
#previewPane {
flex: 1 1 40%;
min-width: 250px;
max-width: 70%;
padding: 0;
overflow-y: auto;
overflow-x: hidden;
background-color: var(--bg-primary);
border-left: 1px solid var(--border-color);
}
#preview {
padding: 20px;
min-height: 100%;
overflow-wrap: break-word;
word-wrap: break-word;
}
#preview > p:first-child {
margin-top: 0;
}
#preview > h1:first-child,
#preview > h2:first-child {
margin-top: 0;
}

3
static/css/modal.css Normal file
View File

@@ -0,0 +1,3 @@
.modal-header .btn-close {
filter: var(--bs-btn-close-white-filter);
}

View File

@@ -12,6 +12,23 @@ let collectionSelector;
let clipboard = null;
let currentFilePath = null;
// Simple event bus
const eventBus = {
listeners: {},
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
},
dispatch(event, data) {
if (this.listeners[event]) {
this.listeners[event].forEach(callback => callback(data));
}
}
};
window.eventBus = eventBus;
// Initialize application
document.addEventListener('DOMContentLoaded', async () => {
// Initialize WebDAV client
@@ -23,22 +40,31 @@ document.addEventListener('DOMContentLoaded', async () => {
darkMode.toggle();
});
// Initialize file tree
fileTree = new FileTree('fileTree', webdavClient);
fileTree.onFileSelect = async (item) => {
await editor.loadFile(item.path);
};
// Initialize collection selector
collectionSelector = new CollectionSelector('collectionSelect', webdavClient);
collectionSelector.onChange = async (collection) => {
await fileTree.load();
};
await collectionSelector.load();
// Initialize file tree
fileTree = new FileTree('fileTree', webdavClient);
fileTree.onFileSelect = async (item) => {
await loadFile(item.path);
};
await fileTree.load();
// Initialize editor
editor = new MarkdownEditor('editor', 'preview');
editor = new MarkdownEditor('editor', 'preview', 'filenameInput');
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();
}
}, 200);
// Setup editor drop handler
const editorDropHandler = new EditorDropHandler(
@@ -50,15 +76,15 @@ document.addEventListener('DOMContentLoaded', async () => {
// Setup button handlers
document.getElementById('newBtn').addEventListener('click', () => {
newFile();
editor.newFile();
});
document.getElementById('saveBtn').addEventListener('click', async () => {
await saveFile();
await editor.save();
});
document.getElementById('deleteBtn').addEventListener('click', async () => {
await deleteCurrentFile();
await editor.deleteFile();
});
// Setup context menu handlers
@@ -66,71 +92,34 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize mermaid
mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' });
// Initialize file tree actions manager
window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor);
// Listen for file-saved event to reload file tree
window.eventBus.on('file-saved', async (path) => {
if (fileTree) {
await fileTree.load();
fileTree.selectNode(path);
}
});
window.eventBus.on('file-deleted', async () => {
if (fileTree) {
await fileTree.load();
}
});
});
// Listen for column resize events to refresh editor
window.addEventListener('column-resize', () => {
if (editor && editor.editor) {
editor.editor.refresh();
}
});
/**
* File Operations
*/
async function loadFile(path) {
try {
const content = await webdavClient.get(path);
editor.setValue(content);
document.getElementById('filenameInput').value = path;
currentFilePath = path;
showNotification('File loaded', 'success');
} catch (error) {
console.error('Failed to load file:', error);
showNotification('Failed to load file', 'error');
}
}
function newFile() {
editor.setValue('# New File\n\nStart typing...\n');
document.getElementById('filenameInput').value = '';
document.getElementById('filenameInput').focus();
currentFilePath = null;
showNotification('New file', 'info');
}
async function saveFile() {
const filename = document.getElementById('filenameInput').value.trim();
if (!filename) {
showNotification('Please enter a filename', 'warning');
return;
}
try {
const content = editor.getValue();
await webdavClient.put(filename, content);
currentFilePath = filename;
await fileTree.load();
showNotification('Saved', 'success');
} catch (error) {
console.error('Failed to save file:', error);
showNotification('Failed to save file', 'error');
}
}
async function deleteCurrentFile() {
if (!currentFilePath) {
showNotification('No file selected', 'warning');
return;
}
if (!confirm(`Delete ${currentFilePath}?`)) {
return;
}
try {
await webdavClient.delete(currentFilePath);
await fileTree.load();
newFile();
showNotification('Deleted', 'success');
} catch (error) {
console.error('Failed to delete file:', error);
showNotification('Failed to delete file', 'error');
}
}
/**
* Context Menu Handlers
@@ -148,117 +137,12 @@ function setupContextMenuHandlers() {
hideContextMenu();
await handleContextAction(action, targetPath, isDir);
await window.fileTreeActions.execute(action, targetPath, isDir);
});
}
async function handleContextAction(action, targetPath, isDir) {
switch (action) {
case 'open':
if (!isDir) {
await loadFile(targetPath);
}
break;
case 'new-file':
if (isDir) {
const filename = prompt('Enter filename:');
if (filename) {
await fileTree.createFile(targetPath, filename);
}
}
break;
case 'new-folder':
if (isDir) {
const foldername = prompt('Enter folder name:');
if (foldername) {
await fileTree.createFolder(targetPath, foldername);
}
}
break;
case 'upload':
if (isDir) {
showFileUploadDialog(targetPath, async (path, file) => {
await fileTree.uploadFile(path, file);
});
}
break;
case 'download':
if (isDir) {
await fileTree.downloadFolder(targetPath);
} else {
await fileTree.downloadFile(targetPath);
}
break;
case 'rename':
const newName = prompt('Enter new name:', targetPath.split('/').pop());
if (newName) {
const parentPath = targetPath.split('/').slice(0, -1).join('/');
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
try {
await webdavClient.move(targetPath, newPath);
await fileTree.load();
showNotification('Renamed', 'success');
} catch (error) {
console.error('Failed to rename:', error);
showNotification('Failed to rename', 'error');
}
}
break;
case 'copy':
clipboard = { path: targetPath, operation: 'copy' };
showNotification('Copied to clipboard', 'info');
updatePasteVisibility();
break;
case 'cut':
clipboard = { path: targetPath, operation: 'cut' };
showNotification('Cut to clipboard', 'info');
updatePasteVisibility();
break;
case 'paste':
if (clipboard && isDir) {
const filename = clipboard.path.split('/').pop();
const destPath = `${targetPath}/${filename}`;
try {
if (clipboard.operation === 'copy') {
await webdavClient.copy(clipboard.path, destPath);
showNotification('Copied', 'success');
} else {
await webdavClient.move(clipboard.path, destPath);
showNotification('Moved', 'success');
clipboard = null;
updatePasteVisibility();
}
await fileTree.load();
} catch (error) {
console.error('Failed to paste:', error);
showNotification('Failed to paste', 'error');
}
}
break;
case 'delete':
if (confirm(`Delete ${targetPath}?`)) {
try {
await webdavClient.delete(targetPath);
await fileTree.load();
showNotification('Deleted', 'success');
} catch (error) {
console.error('Failed to delete:', error);
showNotification('Failed to delete', 'error');
}
}
break;
}
}
// All context actions are now handled by FileTreeActions, so this function is no longer needed.
// async function handleContextAction(action, targetPath, isDir) { ... }
function updatePasteVisibility() {
const pasteItem = document.getElementById('pasteMenuItem');

102
static/js/column-resizer.js Normal file
View File

@@ -0,0 +1,102 @@
/**
* Column Resizer Module
* Handles draggable column dividers
*/
class ColumnResizer {
constructor() {
this.resizer1 = document.getElementById('resizer1');
this.resizer2 = document.getElementById('resizer2');
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,
editor: this.editorPane.offsetWidth,
preview: this.previewPane.offsetWidth
};
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);
}
}
}
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', () => {
window.columnResizer = new ColumnResizer();
});

68
static/js/confirmation.js Normal file
View File

@@ -0,0 +1,68 @@
/**
* Confirmation Modal Manager
* Handles showing and hiding a Bootstrap modal for confirmations and prompts.
*/
class Confirmation {
constructor(modalId) {
this.modalElement = document.getElementById(modalId);
this.modal = new bootstrap.Modal(this.modalElement);
this.messageElement = this.modalElement.querySelector('#confirmationMessage');
this.inputElement = this.modalElement.querySelector('#confirmationInput');
this.confirmButton = this.modalElement.querySelector('#confirmButton');
this.titleElement = this.modalElement.querySelector('.modal-title');
this.currentResolver = null;
}
_show(message, title, showInput = false, defaultValue = '') {
return new Promise((resolve) => {
this.currentResolver = resolve;
this.titleElement.textContent = title;
this.messageElement.textContent = message;
if (showInput) {
this.inputElement.style.display = 'block';
this.inputElement.value = defaultValue;
this.inputElement.focus();
} else {
this.inputElement.style.display = 'none';
}
this.confirmButton.onclick = () => this._handleConfirm(showInput);
this.modalElement.addEventListener('hidden.bs.modal', () => this._handleCancel(), { once: true });
this.modal.show();
});
}
_handleConfirm(isPrompt) {
if (this.currentResolver) {
const value = isPrompt ? this.inputElement.value : true;
this.currentResolver(value);
this._cleanup();
}
}
_handleCancel() {
if (this.currentResolver) {
this.currentResolver(null); // Resolve with null for cancellation
this._cleanup();
}
}
_cleanup() {
this.confirmButton.onclick = null;
this.modal.hide();
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);
}
}
// Make it globally available
window.ConfirmationManager = new Confirmation('confirmationModal');

View File

@@ -11,6 +11,9 @@ class MarkdownEditor {
this.currentFile = null;
this.webdavClient = null;
// Initialize macro processor AFTER webdavClient is set
this.macroProcessor = null;
this.initCodeMirror();
this.initMarkdown();
this.initMermaid();
@@ -32,10 +35,15 @@ class MarkdownEditor {
}
});
// Update preview on change
this.editor.on('change', () => {
// Update preview on change with debouncing
this.editor.on('change', this.debounce(() => {
this.updatePreview();
});
}, 300));
// Initial preview render
setTimeout(() => {
this.updatePreview();
}, 100);
// Sync scroll
this.editor.on('scroll', () => {
@@ -47,17 +55,21 @@ class MarkdownEditor {
* Initialize markdown parser
*/
initMarkdown() {
this.marked = window.marked;
this.marked.setOptions({
breaks: true,
gfm: true,
highlight: (code, lang) => {
if (lang && window.Prism.languages[lang]) {
return window.Prism.highlight(code, window.Prism.languages[lang], lang);
if (window.marked) {
this.marked = window.marked;
this.marked.setOptions({
breaks: true,
gfm: true,
highlight: (code, lang) => {
if (lang && window.Prism.languages[lang]) {
return window.Prism.highlight(code, window.Prism.languages[lang], lang);
}
return code;
}
return code;
}
});
});
} else {
console.error('Marked library not found.');
}
}
/**
@@ -77,6 +89,9 @@ class MarkdownEditor {
*/
setWebDAVClient(client) {
this.webdavClient = client;
// NOW initialize macro processor
this.macroProcessor = new MacroProcessor(client);
}
/**
@@ -123,10 +138,9 @@ class MarkdownEditor {
window.showNotification('✅ Saved', 'success');
}
// Trigger file tree reload
if (window.fileTree) {
await window.fileTree.load();
window.fileTree.selectNode(path);
// Dispatch event to reload file tree
if (window.eventBus) {
window.eventBus.dispatch('file-saved', path);
}
} catch (error) {
console.error('Failed to save file:', error);
@@ -143,7 +157,7 @@ class MarkdownEditor {
this.currentFile = null;
this.filenameInput.value = '';
this.filenameInput.focus();
this.editor.setValue('');
this.editor.setValue('# New File\n\nStart typing...\n');
this.updatePreview();
if (window.showNotification) {
@@ -156,32 +170,19 @@ class MarkdownEditor {
*/
async deleteFile() {
if (!this.currentFile) {
if (window.showNotification) {
window.showNotification('No file selected', 'warning');
}
window.showNotification('No file selected', 'warning');
return;
}
if (!confirm(`Delete ${this.currentFile}?`)) {
return;
}
try {
await this.webdavClient.delete(this.currentFile);
if (window.showNotification) {
const confirmed = await window.ConfirmationManager.confirm(`Are you sure you want to delete ${this.currentFile}?`, 'Delete File');
if (confirmed) {
try {
await this.webdavClient.delete(this.currentFile);
window.showNotification(`Deleted ${this.currentFile}`, 'success');
}
this.newFile();
// Trigger file tree reload
if (window.fileTree) {
await window.fileTree.load();
}
} catch (error) {
console.error('Failed to delete file:', error);
if (window.showNotification) {
this.newFile();
window.eventBus.dispatch('file-deleted');
} catch (error) {
console.error('Failed to delete file:', error);
window.showNotification('Failed to delete file', 'danger');
}
}
@@ -190,26 +191,61 @@ class MarkdownEditor {
/**
* Update preview
*/
updatePreview() {
async updatePreview() {
const markdown = this.editor.getValue();
let html = this.marked.parse(markdown);
const previewDiv = this.previewElement;
// Process mermaid diagrams
html = html.replace(/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g, (match, code) => {
const id = 'mermaid-' + Math.random().toString(36).substr(2, 9);
return `<div class="mermaid" id="${id}">${code}</div>`;
});
this.previewElement.innerHTML = html;
// Render mermaid diagrams
if (window.mermaid) {
window.mermaid.init(undefined, this.previewElement.querySelectorAll('.mermaid'));
if (!markdown || !markdown.trim()) {
previewDiv.innerHTML = `<div class="text-muted">Start typing...</div>`;
return;
}
// Highlight code blocks
if (window.Prism) {
window.Prism.highlightAllUnder(this.previewElement);
try {
// Step 1: Process macros
console.log('[Editor] Processing macros...');
let processedContent = markdown;
if (this.macroProcessor) {
const result = await this.macroProcessor.processMacros(markdown);
processedContent = result.content;
if (result.errors.length > 0) {
console.warn('[Editor] Macro errors:', result.errors);
}
}
// Step 2: Parse markdown
console.log('[Editor] Parsing markdown...');
let html = this.marked.parse(processedContent);
// Step 3: Handle mermaid
html = html.replace(
/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g,
(match, code) => {
const id = 'mermaid-' + Math.random().toString(36).substr(2, 9);
return `<div class="mermaid" id="${id}">${code.trim()}</div>`;
}
);
previewDiv.innerHTML = html;
// Step 4: Syntax highlighting
const codeBlocks = previewDiv.querySelectorAll('pre code');
codeBlocks.forEach(block => {
const lang = Array.from(block.classList)
.find(cls => cls.startsWith('language-'));
if (lang && lang !== 'language-mermaid' && window.Prism) {
window.Prism.highlightElement(block);
}
});
// Step 5: Render mermaid
if (window.mermaid) {
await window.mermaid.run();
}
} catch (error) {
console.error('[Editor] Preview error:', error);
previewDiv.innerHTML = `<div class="alert alert-danger">Error: ${error.message}</div>`;
}
}
@@ -266,6 +302,21 @@ class MarkdownEditor {
setValue(content) {
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);
};
}
}
// Export for use in other modules

View File

@@ -0,0 +1,362 @@
/**
* File Tree Actions Manager
* Centralized handling of all tree operations
*/
class FileTreeActions {
constructor(webdavClient, fileTree, editor) {
this.webdavClient = webdavClient;
this.fileTree = fileTree;
this.editor = editor;
this.clipboard = null;
}
/**
* Validate and sanitize filename/folder name
* Returns { valid: boolean, sanitized: string, message: string }
*/
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: '' };
}
async execute(action, targetPath, isDirectory) {
const handler = this.actions[action];
if (!handler) {
console.error(`Unknown action: ${action}`);
return;
}
try {
await handler.call(this, targetPath, isDirectory);
} catch (error) {
console.error(`Action failed: ${action}`, error);
showNotification(`Failed to ${action}`, 'error');
}
}
actions = {
open: async function(path, isDir) {
if (!isDir) {
await this.editor.loadFile(path);
}
},
'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;
}
} 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);
});
},
'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;
}
} else {
return;
}
}
const fullPath = `${path}/${foldername}`.replace(/\/+/g, '/');
await this.webdavClient.mkcol(fullPath);
await this.fileTree.load();
showNotification(`Created folder ${foldername}`, 'success');
});
},
rename: async function(path, isDir) {
const oldName = path.split('/').pop();
const newName = await this.showInputDialog('Rename to:', oldName);
if (newName && newName !== oldName) {
const parentPath = path.substring(0, path.lastIndexOf('/'));
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
await this.webdavClient.move(path, newPath);
await this.fileTree.load();
showNotification('Renamed', 'success');
}
},
copy: async function(path, isDir) {
this.clipboard = { path, operation: 'copy', isDirectory: isDir };
showNotification(`Copied: ${path.split('/').pop()}`, 'info');
this.updatePasteMenuItem();
},
cut: async function(path, isDir) {
this.clipboard = { path, operation: 'cut', isDirectory: isDir };
showNotification(`Cut: ${path.split('/').pop()}`, 'warning');
this.updatePasteMenuItem();
},
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');
} else {
await this.webdavClient.move(this.clipboard.path, destPath);
this.clipboard = null;
this.updatePasteMenuItem();
showNotification('Moved', 'success');
}
await this.fileTree.load();
},
delete: async function(path, isDir) {
const name = path.split('/').pop();
const type = isDir ? 'folder' : 'file';
if (!await this.showConfirmDialog(`Delete this ${type}?`, `${name}`)) {
return;
}
await this.webdavClient.delete(path);
await this.fileTree.load();
showNotification(`Deleted ${name}`, 'success');
},
download: async function(path, isDir) {
showNotification('Downloading...', 'info');
// Implementation here
},
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) {
const fullPath = `${path}/${file.name}`.replace(/\/+/g, '/');
const content = await file.arrayBuffer();
await this.webdavClient.putBinary(fullPath, content);
showNotification(`Uploaded ${file.name}`, 'success');
}
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;
}
updatePasteMenuItem() {
const pasteItem = document.getElementById('pasteMenuItem');
if (pasteItem) {
pasteItem.style.display = this.clipboard ? 'flex' : 'none';
}
}
}

View File

@@ -16,7 +16,6 @@ class FileTree {
}
setupEventListeners() {
// Click handler for tree nodes
this.container.addEventListener('click', (e) => {
const node = e.target.closest('.tree-node');
if (!node) return;
@@ -24,30 +23,107 @@ class FileTree {
const path = node.dataset.path;
const isDir = node.dataset.isdir === 'true';
// Toggle folder
if (e.target.closest('.tree-toggle')) {
this.toggleFolder(node);
// If it's a directory, and the click was on the title, select the folder
if (isDir && e.target.classList.contains('tree-node-title')) {
this.selectFolder(path);
} else if (!isDir) { // If it's a file, select the file
this.selectFile(path);
}
// Clicks on the toggle are handled by the toggle's specific event listener
});
// DRAG AND DROP
this.container.addEventListener('dragstart', (e) => {
const node = e.target.closest('.tree-node');
if (!node) return;
const path = node.dataset.path;
const isDir = node.dataset.isdir === 'true';
console.log('[FileTree] Drag start:', path);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', path);
e.dataTransfer.setData('application/json', JSON.stringify({
path,
isDir,
name: node.querySelector('.tree-node-title').textContent
}));
node.classList.add('dragging');
setTimeout(() => node.classList.remove('dragging'), 0);
});
this.container.addEventListener('dragover', (e) => {
const node = e.target.closest('.tree-node');
if (!node) return;
const isDir = node.dataset.isdir === 'true';
if (!isDir) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
node.classList.add('drag-over');
});
this.container.addEventListener('dragleave', (e) => {
const node = e.target.closest('.tree-node');
if (node) {
node.classList.remove('drag-over');
}
});
this.container.addEventListener('drop', async (e) => {
const targetNode = e.target.closest('.tree-node');
if (!targetNode) return;
e.preventDefault();
e.stopPropagation();
const targetPath = targetNode.dataset.path;
const isDir = targetNode.dataset.isdir === 'true';
if (!isDir) {
console.log('[FileTree] Target is not a directory');
return;
}
// Select node
if (isDir) {
this.selectFolder(path);
} else {
this.selectFile(path);
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'));
const sourcePath = data.path;
const sourceName = data.name;
if (sourcePath === targetPath) {
console.log('[FileTree] Source and target are same');
return;
}
const destPath = `${targetPath}/${sourceName}`.replace(/\/+/g, '/');
console.log('[FileTree] Moving:', sourcePath, '→', destPath);
await this.webdavClient.move(sourcePath, destPath);
await this.load();
showNotification(`Moved to ${targetNode.querySelector('.tree-node-title').textContent}`, 'success');
} catch (error) {
console.error('[FileTree] Drop error:', error);
showNotification(`Failed to move: ${error.message}`, 'error');
} finally {
targetNode.classList.remove('drag-over');
}
});
// Context menu
this.container.addEventListener('contextmenu', (e) => {
const node = e.target.closest('.tree-node');
if (!node) return;
e.preventDefault();
const path = node.dataset.path;
const isDir = node.dataset.isdir === 'true';
window.showContextMenu(e.clientX, e.clientY, { path, isDir });
if (node) {
const path = node.dataset.path;
const isDir = node.dataset.isdir === 'true';
window.showContextMenu(e.clientX, e.clientY, { path, isDir });
}
});
}
@@ -69,83 +145,36 @@ class FileTree {
renderNodes(nodes, parentElement, level) {
nodes.forEach(node => {
const nodeElement = this.createNodeElement(node, level);
parentElement.appendChild(nodeElement);
const nodeWrapper = document.createElement('div');
nodeWrapper.className = 'tree-node-wrapper';
// Create node element
const nodeElement = this.createNodeElement(node, level);
nodeWrapper.appendChild(nodeElement);
// Create children container ONLY if has children
if (node.children && node.children.length > 0) {
const childContainer = document.createElement('div');
childContainer.className = 'tree-children';
childContainer.style.display = 'none';
nodeElement.appendChild(childContainer);
childContainer.dataset.parent = node.path;
childContainer.style.marginLeft = `${(level + 1) * 12}px`;
// Recursively render children
this.renderNodes(node.children, childContainer, level + 1);
nodeWrapper.appendChild(childContainer);
// Make toggle functional
// The toggle functionality is already handled in renderNodes, no need to duplicate here.
// Ensure the toggle's click event stops propagation to prevent the parent node's click from firing.
}
parentElement.appendChild(nodeWrapper);
});
}
createNodeElement(node, level) {
const div = document.createElement('div');
div.className = 'tree-node';
div.dataset.path = node.path;
div.dataset.isdir = node.isDirectory;
div.style.paddingLeft = `${level * 20 + 10}px`;
// Toggle arrow for folders
if (node.isDirectory) {
const toggle = document.createElement('span');
toggle.className = 'tree-toggle';
toggle.innerHTML = '<i class="bi bi-chevron-right"></i>';
div.appendChild(toggle);
} else {
const spacer = document.createElement('span');
spacer.className = 'tree-spacer';
spacer.style.width = '16px';
spacer.style.display = 'inline-block';
div.appendChild(spacer);
}
// Icon
const icon = document.createElement('i');
if (node.isDirectory) {
icon.className = 'bi bi-folder-fill';
icon.style.color = '#dcb67a';
} else {
icon.className = 'bi bi-file-earmark-text';
icon.style.color = '#6a9fb5';
}
div.appendChild(icon);
// Name
const name = document.createElement('span');
name.className = 'tree-name';
name.textContent = node.name;
div.appendChild(name);
// Size for files
if (!node.isDirectory && node.size) {
const size = document.createElement('span');
size.className = 'tree-size';
size.textContent = this.formatSize(node.size);
div.appendChild(size);
}
return div;
}
toggleFolder(nodeElement) {
const childContainer = nodeElement.querySelector('.tree-children');
if (!childContainer) return;
const toggle = nodeElement.querySelector('.tree-toggle i');
const isExpanded = childContainer.style.display !== 'none';
if (isExpanded) {
childContainer.style.display = 'none';
toggle.className = 'bi bi-chevron-right';
} else {
childContainer.style.display = 'block';
toggle.className = 'bi bi-chevron-down';
}
}
// toggleFolder is no longer needed as the event listener is added in renderNodes.
selectFile(path) {
this.selectedPath = path;
@@ -178,6 +207,32 @@ class FileTree {
}
}
createNodeElement(node, level) {
const nodeElement = document.createElement('div');
nodeElement.className = 'tree-node';
nodeElement.dataset.path = node.path;
nodeElement.dataset.isdir = node.isDirectory;
nodeElement.style.paddingLeft = `${level * 12}px`;
const icon = document.createElement('span');
icon.className = 'tree-node-icon';
if (node.isDirectory) {
icon.innerHTML = '▶'; // Collapsed by default
icon.classList.add('tree-node-toggle');
} else {
icon.innerHTML = '●'; // File icon
}
const title = document.createElement('span');
title.className = 'tree-node-title';
title.textContent = node.name;
nodeElement.appendChild(icon);
nodeElement.appendChild(title);
return nodeElement;
}
formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -186,11 +241,21 @@ class FileTree {
return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
}
newFile() {
this.selectedPath = null;
this.updateSelection();
// Potentially clear editor via callback
if (this.onFileSelect) {
this.onFileSelect({ path: null, isDirectory: false });
}
}
async createFile(parentPath, filename) {
try {
const fullPath = parentPath ? `${parentPath}/${filename}` : filename;
await this.webdavClient.put(fullPath, '# New File\n\nStart typing...\n');
await this.load();
this.selectFile(fullPath); // Select the new file
showNotification('File created', 'success');
return fullPath;
} catch (error) {

149
static/js/macro-parser.js Normal file
View File

@@ -0,0 +1,149 @@
/**
* Macro Parser and Processor
* Parses HeroScript-style macros from markdown content
*/
class MacroParser {
/**
* Extract macros with improved parsing
*/
static extractMacros(content) {
const macroRegex = /!!([\w.]+)\s*([\s\S]*?)(?=\n!!|\n#|$)/g;
const macros = [];
let match;
while ((match = macroRegex.exec(content)) !== null) {
const fullMatch = match[0];
const actionPart = match[1];
const paramsPart = match[2];
const [actor, method] = actionPart.includes('.')
? actionPart.split('.')
: ['core', actionPart];
const params = this.parseParams(paramsPart);
console.log(`[MacroParser] Extracted: !!${actor}.${method}`, params);
macros.push({
fullMatch: fullMatch.trim(),
actor,
method,
params,
start: match.index,
end: match.index + fullMatch.length
});
}
return macros;
}
/**
* Parse HeroScript parameters with multiline support
* Supports:
* key: 'value'
* key: '''multiline value'''
* key: |
* multiline
* value
*/
static parseParams(paramsPart) {
const params = {};
if (!paramsPart || !paramsPart.trim()) {
return params;
}
let lines = paramsPart.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i].trim();
if (!line) {
i++;
continue;
}
// Check for key: value pattern
if (line.includes(':')) {
const colonIndex = line.indexOf(':');
const key = line.substring(0, colonIndex).trim();
let value = line.substring(colonIndex + 1).trim();
// Handle triple-quoted multiline
if (value.startsWith("'''")) {
value = value.substring(3);
const valueLines = [value];
i++;
while (i < lines.length) {
const contentLine = lines[i];
if (contentLine.trim().endsWith("'''")) {
valueLines.push(contentLine.trim().slice(0, -3));
break;
}
valueLines.push(contentLine);
i++;
}
// Remove leading whitespace from multiline
const processedValue = this.dedent(valueLines.join('\n'));
params[key] = processedValue;
}
// Handle pipe multiline
else if (value === '|') {
const valueLines = [];
i++;
while (i < lines.length && lines[i].startsWith('\t')) {
valueLines.push(lines[i].substring(1)); // Remove tab
i++;
}
i--; // Back up one since loop will increment
const processedValue = this.dedent(valueLines.join('\n'));
params[key] = processedValue;
}
// Handle quoted value
else if (value.startsWith("'") && value.endsWith("'")) {
params[key] = value.slice(1, -1);
}
// Handle unquoted value
else {
params[key] = value;
}
}
i++;
}
console.log(`[MacroParser] Parsed parameters:`, params);
return params;
}
/**
* Remove common leading whitespace from multiline strings
*/
static dedent(text) {
const lines = text.split('\n');
// Find minimum indentation
let minIndent = Infinity;
for (const line of lines) {
if (line.trim().length === 0) continue;
const indent = line.search(/\S/);
minIndent = Math.min(minIndent, indent);
}
if (minIndent === Infinity) minIndent = 0;
// Remove common indentation
return lines
.map(line => line.slice(minIndent))
.join('\n')
.trim();
}
}
window.MacroParser = MacroParser;

View File

@@ -0,0 +1,133 @@
/**
* Macro Processor
* Handles macro execution and result rendering
*/
class MacroProcessor {
constructor(webdavClient) {
this.webdavClient = webdavClient;
this.macroRegistry = new MacroRegistry();
this.includeStack = [];
this.faqItems = [];
this.registerDefaultPlugins();
console.log('[MacroProcessor] Initialized');
}
/**
* Process all macros in markdown
*/
async processMacros(content) {
console.log('[MacroProcessor] Processing content, length:', content.length);
const macros = MacroParser.extractMacros(content);
console.log(`[MacroProcessor] Found ${macros.length} macros`);
const errors = [];
let processedContent = content;
let faqOutput = '';
// Process in reverse to preserve positions
for (let i = macros.length - 1; i >= 0; i--) {
const macro = macros[i];
console.log(`[MacroProcessor] Processing macro ${i}:`, macro.actor, macro.method);
try {
const result = await this.processMacro(macro);
if (result.success) {
console.log(`[MacroProcessor] Macro succeeded, replacing content`);
processedContent =
processedContent.substring(0, macro.start) +
result.content +
processedContent.substring(macro.end);
} else {
console.error(`[MacroProcessor] Macro failed:`, result.error);
errors.push({ macro: macro.fullMatch, error: result.error });
const errorMsg = `\n\n⚠️ **Macro Error**: ${result.error}\n\n`;
processedContent =
processedContent.substring(0, macro.start) +
errorMsg +
processedContent.substring(macro.end);
}
} catch (error) {
console.error(`[MacroProcessor] Macro exception:`, error);
errors.push({ macro: macro.fullMatch, error: error.message });
const errorMsg = `\n\n⚠️ **Macro Exception**: ${error.message}\n\n`;
processedContent =
processedContent.substring(0, macro.start) +
errorMsg +
processedContent.substring(macro.end);
}
}
// Append FAQ if any FAQ macros were used
if (this.faqItems.length > 0) {
faqOutput = '\n\n---\n\n## FAQ\n\n';
faqOutput += this.faqItems
.map((item, idx) => `### ${idx + 1}. ${item.title}\n\n${item.response}`)
.join('\n\n');
processedContent += faqOutput;
}
console.log('[MacroProcessor] Processing complete, errors:', errors.length);
return {
success: errors.length === 0,
content: processedContent,
errors
};
}
/**
* Process single macro
*/
async processMacro(macro) {
const plugin = this.macroRegistry.resolve(macro.actor, macro.method);
if (!plugin) {
return {
success: false,
error: `Unknown macro: !!${macro.actor}.${macro.method}`
};
}
// Check for circular includes
if (macro.method === 'include') {
const path = macro.params.path;
if (this.includeStack.includes(path)) {
return {
success: false,
error: `Circular include: ${this.includeStack.join(' → ')}${path}`
};
}
}
try {
return await plugin.process(macro, this.webdavClient);
} catch (error) {
console.error('[MacroProcessor] Plugin error:', error);
return {
success: false,
error: `Plugin error: ${error.message}`
};
}
}
/**
* Register default plugins
*/
registerDefaultPlugins() {
// Include plugin
this.macroRegistry.register('core', 'include', new IncludePlugin(this));
// FAQ plugin
this.macroRegistry.register('core', 'faq', new FAQPlugin(this));
console.log('[MacroProcessor] Registered default plugins');
}
}
window.MacroProcessor = MacroProcessor;

50
static/js/macro-system.js Normal file
View File

@@ -0,0 +1,50 @@
/**
* Macro System
* Generic plugin-based macro processor
*/
class MacroPlugin {
/**
* Base class for macro plugins
* Subclass and implement these methods:
* - canHandle(actor, method): boolean
* - process(macro, context): Promise<{ success, content, error }>
*/
canHandle(actor, method) {
throw new Error('Must implement canHandle()');
}
async process(macro, context) {
throw new Error('Must implement process()');
}
}
class MacroRegistry {
constructor() {
this.plugins = new Map();
console.log('[MacroRegistry] Initializing macro registry');
}
register(actor, method, plugin) {
const key = `${actor}.${method}`;
this.plugins.set(key, plugin);
console.log(`[MacroRegistry] Registered plugin: ${key}`);
}
resolve(actor, method) {
// Try exact match
let key = `${actor}.${method}`;
if (this.plugins.has(key)) {
console.log(`[MacroRegistry] Found plugin: ${key}`);
return this.plugins.get(key);
}
// No plugin found
console.warn(`[MacroRegistry] No plugin found for: ${key}`);
return null;
}
}
window.MacroRegistry = MacroRegistry;
window.MacroPlugin = MacroPlugin;

View File

@@ -0,0 +1,70 @@
/**
* FAQ Plugin
* Creates FAQ entries that are collected and displayed at bottom of preview
*
* Usage:
* !!faq title: 'My Question'
* response: '''
* This is the answer with **markdown** support.
*
* - Point 1
* - Point 2
* '''
*/
class FAQPlugin extends MacroPlugin {
constructor(processor) {
super();
this.processor = processor;
}
canHandle(actor, method) {
return actor === 'core' && method === 'faq';
}
async process(macro, webdavClient) {
const title = macro.params.title;
const response = macro.params.response;
console.log('[FAQPlugin] Processing FAQ:', title);
if (!title) {
console.error('[FAQPlugin] Missing title parameter');
return {
success: false,
error: 'FAQ macro requires "title" parameter'
};
}
if (!response) {
console.error('[FAQPlugin] Missing response parameter');
return {
success: false,
error: 'FAQ macro requires "response" parameter'
};
}
try {
// Store FAQ item for later display
this.processor.faqItems.push({
title: title.trim(),
response: response.trim()
});
console.log('[FAQPlugin] FAQ item added, total:', this.processor.faqItems.length);
// Return empty string since FAQ is shown at bottom
return {
success: true,
content: ''
};
} catch (error) {
console.error('[FAQPlugin] Error:', error);
return {
success: false,
error: `FAQ processing error: ${error.message}`
};
}
}
}
window.FAQPlugin = FAQPlugin;

View File

@@ -0,0 +1,97 @@
/**
* Include Plugin
* Includes content from other files
*
* Usage:
* !!include path: 'myfile.md'
* !!include path: 'collection:folder/file.md'
*/
class IncludePlugin extends MacroPlugin {
constructor(processor) {
super();
this.processor = processor;
}
canHandle(actor, method) {
return actor === 'core' && method === 'include';
}
async process(macro, webdavClient) {
const path = macro.params.path;
console.log('[IncludePlugin] Processing include:', path);
if (!path) {
console.error('[IncludePlugin] Missing path parameter');
return {
success: false,
error: 'Include macro requires "path" parameter'
};
}
try {
// Parse path format: "collection:path/to/file" or "path/to/file"
let targetCollection = webdavClient.currentCollection;
let targetPath = path;
if (path.includes(':')) {
[targetCollection, targetPath] = path.split(':', 2);
console.log('[IncludePlugin] Using external collection:', targetCollection);
} else {
console.log('[IncludePlugin] Using current collection:', targetCollection);
}
// Check for circular includes
const fullPath = `${targetCollection}:${targetPath}`;
if (this.processor.includeStack.includes(fullPath)) {
console.error('[IncludePlugin] Circular include detected');
return {
success: false,
error: `Circular include detected: ${this.processor.includeStack.join(' → ')}${fullPath}`
};
}
// Add to include stack
this.processor.includeStack.push(fullPath);
// Switch collection temporarily
const originalCollection = webdavClient.currentCollection;
webdavClient.setCollection(targetCollection);
// Fetch file
console.log('[IncludePlugin] Fetching:', targetPath);
const content = await webdavClient.get(targetPath);
// Restore collection
webdavClient.setCollection(originalCollection);
// Remove from stack
this.processor.includeStack.pop();
console.log('[IncludePlugin] Include successful, length:', content.length);
return {
success: true,
content: content
};
} catch (error) {
console.error('[IncludePlugin] Error:', error);
// Restore collection on error
if (webdavClient.currentCollection !== this.processor.webdavClient?.currentCollection) {
webdavClient.setCollection(this.processor.webdavClient?.currentCollection);
}
this.processor.includeStack = this.processor.includeStack.filter(
item => !item.includes(path)
);
return {
success: false,
error: `Cannot include "${path}": ${error.message}`
};
}
}
}
window.IncludePlugin = IncludePlugin;

View File

@@ -47,29 +47,26 @@ function showContextMenu(x, y, target) {
const menu = document.getElementById('contextMenu');
if (!menu) return;
// Store target
// Store target data
menu.dataset.targetPath = target.path;
menu.dataset.targetIsDir = target.isDir;
// Show/hide menu items based on target type
const newFileItem = menu.querySelector('[data-action="new-file"]');
const newFolderItem = menu.querySelector('[data-action="new-folder"]');
const uploadItem = menu.querySelector('[data-action="upload"]');
const downloadItem = menu.querySelector('[data-action="download"]');
const items = {
'new-file': target.isDir,
'new-folder': target.isDir,
'upload': target.isDir,
'download': true,
'paste': target.isDir && window.fileTreeActions?.clipboard,
'open': !target.isDir
};
if (target.isDir) {
// Folder context menu
if (newFileItem) newFileItem.style.display = 'block';
if (newFolderItem) newFolderItem.style.display = 'block';
if (uploadItem) uploadItem.style.display = 'block';
if (downloadItem) downloadItem.style.display = 'block';
} else {
// File context menu
if (newFileItem) newFileItem.style.display = 'none';
if (newFolderItem) newFolderItem.style.display = 'none';
if (uploadItem) uploadItem.style.display = 'none';
if (downloadItem) downloadItem.style.display = 'block';
}
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';
@@ -77,13 +74,15 @@ function showContextMenu(x, y, target) {
menu.style.top = y + 'px';
// Adjust if off-screen
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';
}
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() {
@@ -93,9 +92,24 @@ function hideContextMenu() {
}
}
// Hide context menu on click outside
document.addEventListener('click', (e) => {
if (!e.target.closest('#contextMenu')) {
// 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();
}
});

View File

@@ -162,6 +162,31 @@ class WebDAVClient {
return true;
}
async includeFile(path) {
try {
// Parse path: "collection:path/to/file" or "path/to/file"
let targetCollection = this.currentCollection;
let targetPath = path;
if (path.includes(':')) {
[targetCollection, targetPath] = path.split(':');
}
// Temporarily switch collection
const originalCollection = this.currentCollection;
this.currentCollection = targetCollection;
const content = await this.get(targetPath);
// Restore collection
this.currentCollection = originalCollection;
return content;
} catch (error) {
throw new Error(`Cannot include file "${path}": ${error.message}`);
}
}
parseMultiStatus(xml) {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'text/xml');
@@ -231,6 +256,7 @@ class WebDAVClient {
} else {
root.push(node);
}
});
return root;

View File

@@ -23,6 +23,7 @@
<link rel="stylesheet" href="/static/css/file-tree.css">
<link rel="stylesheet" href="/static/css/editor.css">
<link rel="stylesheet" href="/static/css/components.css">
<link rel="stylesheet" href="/static/css/modal.css">
</head>
<body>
@@ -51,19 +52,21 @@
<div class="container-fluid">
<div class="row h-100">
<!-- Sidebar -->
<div class="col-md-2 sidebar">
<div class="col-md-2 sidebar" id="sidebarPane">
<!-- Collection Selector -->
<div class="collection-selector">
<label class="form-label small">Collection:</label>
<select id="collectionSelect" class="form-select form-select-sm"></select>
</div>
<!-- File Tree -->
<div id="fileTree" class="file-tree"></div>
</div>
<!-- Resizer between sidebar and editor -->
<div class="column-resizer" id="resizer1"></div>
<!-- Editor Pane -->
<div class="col-md-5 editor-pane">
<div class="col editor-pane" id="editorPane">
<div class="editor-header">
<input type="text" id="filenameInput" placeholder="filename.md"
class="form-control form-control-sm">
@@ -73,9 +76,11 @@
</div>
</div>
<!-- Resizer between editor and preview -->
<div class="column-resizer" id="resizer2"></div>
<!-- Preview Pane -->
<div class="col-md-5 preview-pane">
<h3>Preview</h3>
<div class="col preview-pane" id="previewPane">
<div id="preview">
<p class="text-muted">Start typing in the editor to see the preview</p>
</div>
@@ -120,6 +125,26 @@
</div>
</div>
<!-- Confirmation Modal -->
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-labelledby="confirmationModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirmationModalLabel">Confirmation</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p id="confirmationMessage"></p>
<input type="text" id="confirmationInput" class="form-control" style="display: none;">
</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" id="confirmButton">OK</button>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
@@ -153,12 +178,20 @@
<!-- Mermaid for diagrams -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<!-- Modular JavaScript -->
<script src="/static/js/webdav-client.js"></script>
<script src="/static/js/file-tree.js"></script>
<script src="/static/js/editor.js"></script>
<script src="/static/js/ui-utils.js"></script>
<script src="/static/js/app.js"></script>
<script src="/static/js/webdav-client.js" defer></script>
<script src="/static/js/file-tree.js" defer></script>
<script src="/static/js/editor.js" defer></script>
<script src="/static/js/ui-utils.js" defer></script>
<script src="/static/js/confirmation.js" defer></script>
<script src="/static/js/file-tree-actions.js" defer></script>
<script src="/static/js/column-resizer.js" defer></script>
<script src="/static/js/app.js" defer></script>
<!-- Macro System -->
<script src="/static/js/macro-system.js" defer></script>
<script src="/static/js/plugins/include-plugin.js" defer></script>
<script src="/static/js/plugins/faq-plugin.js" defer></script>
<script src="/static/js/macro-parser.js" defer></script>
<script src="/static/js/macro-processor.js" defer></script>
</body>
</html>