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': { | ||||
| @@ -73,21 +75,31 @@ 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 | ||||
|         # 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""" | ||||
|         collections = list(self.collections.keys()) | ||||
| @@ -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,6 +55,7 @@ class MarkdownEditor { | ||||
|      * Initialize markdown parser | ||||
|      */ | ||||
|     initMarkdown() { | ||||
|         if (window.marked) { | ||||
|             this.marked = window.marked; | ||||
|             this.marked.setOptions({ | ||||
|                 breaks: true, | ||||
| @@ -58,6 +67,9 @@ class MarkdownEditor { | ||||
|                     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'); | ||||
|             } | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (!confirm(`Delete ${this.currentFile}?`)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         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); | ||||
|              | ||||
|             if (window.showNotification) { | ||||
|                 window.showNotification(`Deleted ${this.currentFile}`, 'success'); | ||||
|             } | ||||
|  | ||||
|                 this.newFile(); | ||||
|  | ||||
|             // Trigger file tree reload | ||||
|             if (window.fileTree) { | ||||
|                 await window.fileTree.load(); | ||||
|             } | ||||
|                 window.eventBus.dispatch('file-deleted'); | ||||
|             } catch (error) { | ||||
|                 console.error('Failed to delete file:', error); | ||||
|             if (window.showNotification) { | ||||
|                 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,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(); | ||||
|  | ||||
|             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
									
								
							
							
						
						
									
										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,6 +74,7 @@ function showContextMenu(x, y, target) { | ||||
|     menu.style.top = y + 'px'; | ||||
|      | ||||
|     // Adjust if off-screen | ||||
|     setTimeout(() => { | ||||
|         const rect = menu.getBoundingClientRect(); | ||||
|         if (rect.right > window.innerWidth) { | ||||
|             menu.style.left = (window.innerWidth - rect.width - 10) + 'px'; | ||||
| @@ -84,6 +82,7 @@ function showContextMenu(x, y, target) { | ||||
|         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