Compare commits
10 Commits
e41e49f7ea
...
8750e0af39
| Author | SHA1 | Date | |
|---|---|---|---|
| 8750e0af39 | |||
| d48e25ce90 | |||
| 11038e0bcd | |||
| cae90ec3dc | |||
| b9349425d7 | |||
| cdc753e72d | |||
| 98a529a3cc | |||
| 5c9e07eee0 | |||
| 12b4685457 | |||
| 3fc8329303 |
@@ -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
10
collections/notes/test.md
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
# test
|
||||
|
||||
- 1
|
||||
- 2
|
||||
|
||||
[2025 SeaweedFS Intro Slides.pdf](/notes/2025 SeaweedFS Intro Slides.pdf)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
|
||||
test
|
||||
# test
|
||||
|
||||
- 1
|
||||
- 2
|
||||
|
||||
|
||||
|
||||
!!include path:test2.md
|
||||
|
||||
12
collections/notes/ttt/test2.md
Normal file
12
collections/notes/ttt/test2.md
Normal file
@@ -0,0 +1,12 @@
|
||||
|
||||
## test2
|
||||
|
||||
- something
|
||||
- another thing
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ collections:
|
||||
|
||||
# Server settings
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
host: "localhost"
|
||||
port: 8004
|
||||
|
||||
# WebDAV settings
|
||||
|
||||
@@ -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': {
|
||||
@@ -72,21 +74,31 @@ class MarkdownEditorApp:
|
||||
"""WSGI application entry point"""
|
||||
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()
|
||||
|
||||
6
start.sh
6
start.sh
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
3
static/css/modal.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.modal-header .btn-close {
|
||||
filter: var(--bs-btn-close-white-filter);
|
||||
}
|
||||
240
static/js/app.js
240
static/js/app.js
@@ -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
102
static/js/column-resizer.js
Normal 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
68
static/js/confirmation.js
Normal 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');
|
||||
@@ -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
|
||||
|
||||
362
static/js/file-tree-actions.js
Normal file
362
static/js/file-tree-actions.js
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,38 +16,114 @@ class FileTree {
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Click handler for tree nodes
|
||||
this.container.addEventListener('click', (e) => {
|
||||
const node = e.target.closest('.tree-node');
|
||||
if (!node) return;
|
||||
|
||||
const path = node.dataset.path;
|
||||
const isDir = node.dataset.isdir === 'true';
|
||||
|
||||
// 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;
|
||||
|
||||
// Toggle folder
|
||||
if (e.target.closest('.tree-toggle')) {
|
||||
this.toggleFolder(node);
|
||||
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 nodeWrapper = document.createElement('div');
|
||||
nodeWrapper.className = 'tree-node-wrapper';
|
||||
|
||||
// Create node element
|
||||
const nodeElement = this.createNodeElement(node, level);
|
||||
parentElement.appendChild(nodeElement);
|
||||
|
||||
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;
|
||||
@@ -177,6 +206,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';
|
||||
@@ -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
149
static/js/macro-parser.js
Normal 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;
|
||||
133
static/js/macro-processor.js
Normal file
133
static/js/macro-processor.js
Normal 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
50
static/js/macro-system.js
Normal 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;
|
||||
70
static/js/plugins/faq-plugin.js
Normal file
70
static/js/plugins/faq-plugin.js
Normal 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;
|
||||
97
static/js/plugins/include-plugin.js
Normal file
97
static/js/plugins/include-plugin.js
Normal 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;
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user