diff --git a/backend/main.py b/backend/main.py
index f848479..544317a 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -32,6 +32,7 @@ from .utils import (
move_folder,
rename_folder,
delete_folder,
+ save_uploaded_image,
)
from .plugins import PluginManager
from .themes import get_available_themes, get_theme_css
@@ -79,6 +80,10 @@ plugin_manager.run_hook('on_app_startup')
static_path = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=static_path), name="static")
+# Mount data directory for serving images
+data_path = Path(config['storage']['notes_dir'])
+app.mount("/data", StaticFiles(directory=data_path), name="data")
+
# ============================================================================
# Custom Exception Handlers
@@ -433,6 +438,61 @@ async def create_new_folder(data: dict):
raise HTTPException(status_code=500, detail=str(e))
+@api_router.post("/upload-image")
+async def upload_image(file: UploadFile = File(...), note_path: str = Form(...)):
+ """
+ Upload an image file and save it to the attachments directory.
+ Returns the relative path to the image for markdown linking.
+ """
+ try:
+ # Validate file type
+ allowed_types = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'}
+ allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
+
+ # Get file extension
+ file_ext = Path(file.filename).suffix.lower() if file.filename else ''
+
+ if file.content_type not in allowed_types and file_ext not in allowed_extensions:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Invalid file type. Allowed: jpg, jpeg, png, gif, webp. Got: {file.content_type}"
+ )
+
+ # Read file data
+ file_data = await file.read()
+
+ # Validate file size (10MB max)
+ max_size = 10 * 1024 * 1024 # 10MB in bytes
+ if len(file_data) > max_size:
+ raise HTTPException(
+ status_code=400,
+ detail=f"File too large. Maximum size: 10MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB"
+ )
+
+ # Save the image
+ image_path = save_uploaded_image(
+ config['storage']['notes_dir'],
+ note_path,
+ file.filename,
+ file_data
+ )
+
+ if not image_path:
+ raise HTTPException(status_code=500, detail="Failed to save image")
+
+ return {
+ "success": True,
+ "path": image_path,
+ "filename": Path(image_path).name,
+ "message": "Image uploaded successfully"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@api_router.post("/notes/move")
async def move_note_endpoint(data: dict):
"""Move a note to a different folder"""
diff --git a/backend/utils.py b/backend/utils.py
index 9020cbd..e953e93 100644
--- a/backend/utils.py
+++ b/backend/utils.py
@@ -157,23 +157,29 @@ def delete_folder(notes_dir: str, folder_path: str) -> bool:
def get_all_notes(notes_dir: str) -> List[Dict]:
- """Recursively get all markdown notes"""
- notes = []
+ """Recursively get all markdown notes and images"""
+ items = []
notes_path = Path(notes_dir)
+ # Get all markdown notes
for md_file in notes_path.rglob("*.md"):
relative_path = md_file.relative_to(notes_path)
stat = md_file.stat()
- notes.append({
+ items.append({
"name": md_file.stem,
"path": str(relative_path.as_posix()),
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
- "size": stat.st_size
+ "size": stat.st_size,
+ "type": "note"
})
- return sorted(notes, key=lambda x: x['modified'], reverse=True)
+ # Get all images
+ images = get_all_images(notes_dir)
+ items.extend(images)
+
+ return sorted(items, key=lambda x: x['modified'], reverse=True)
def get_note_content(notes_dir: str, note_path: str) -> Optional[str]:
@@ -296,3 +302,127 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict:
"lines": line_count
}
+
+def sanitize_filename(filename: str) -> str:
+ """
+ Sanitize a filename by removing/replacing invalid characters.
+ Keeps only alphanumeric chars, dots, dashes, and underscores.
+ """
+ # Get the extension first
+ parts = filename.rsplit('.', 1)
+ name = parts[0]
+ ext = parts[1] if len(parts) > 1 else ''
+
+ # Remove/replace invalid characters
+ name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
+
+ # Rejoin with extension
+ return f"{name}.{ext}" if ext else name
+
+
+def get_attachment_dir(notes_dir: str, note_path: str) -> Path:
+ """
+ Get the attachments directory for a given note.
+ If note is in root, returns /data/_attachments/
+ If note is in folder, returns /data/folder/_attachments/
+ """
+ if not note_path:
+ # Root level
+ return Path(notes_dir) / "_attachments"
+
+ note_path_obj = Path(note_path)
+ folder = note_path_obj.parent
+
+ if str(folder) == '.':
+ # Note is in root
+ return Path(notes_dir) / "_attachments"
+ else:
+ # Note is in a folder
+ return Path(notes_dir) / folder / "_attachments"
+
+
+def save_uploaded_image(notes_dir: str, note_path: str, filename: str, file_data: bytes) -> Optional[str]:
+ """
+ Save an uploaded image to the appropriate attachments directory.
+ Returns the relative path to the image if successful, None otherwise.
+
+ Args:
+ notes_dir: Base notes directory
+ note_path: Path of the note the image is being uploaded to
+ filename: Original filename
+ file_data: Binary file data
+
+ Returns:
+ Relative path to the saved image, or None if failed
+ """
+ # Sanitize filename
+ sanitized_name = sanitize_filename(filename)
+
+ # Get extension
+ ext = Path(sanitized_name).suffix
+ name_without_ext = Path(sanitized_name).stem
+
+ # Add timestamp to prevent collisions
+ timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
+ final_filename = f"{name_without_ext}-{timestamp}{ext}"
+
+ # Get attachments directory
+ attachments_dir = get_attachment_dir(notes_dir, note_path)
+
+ # Create directory if it doesn't exist
+ attachments_dir.mkdir(parents=True, exist_ok=True)
+
+ # Full path to save the image
+ full_path = attachments_dir / final_filename
+
+ # Security check
+ if not validate_path_security(notes_dir, full_path):
+ print(f"Security: Attempted to save image outside notes directory: {full_path}")
+ return None
+
+ try:
+ # Write the file
+ with open(full_path, 'wb') as f:
+ f.write(file_data)
+
+ # Return relative path from notes_dir
+ relative_path = full_path.relative_to(Path(notes_dir))
+ return str(relative_path.as_posix())
+ except Exception as e:
+ print(f"Error saving image: {e}")
+ return None
+
+
+def get_all_images(notes_dir: str) -> List[Dict]:
+ """
+ Get all images from attachments directories.
+ Returns list of image dictionaries with metadata.
+ """
+ images = []
+ notes_path = Path(notes_dir)
+
+ # Common image extensions
+ image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
+
+ # Find all attachments directories
+ for attachments_dir in notes_path.rglob("_attachments"):
+ if not attachments_dir.is_dir():
+ continue
+
+ # Find all images in this attachments directory
+ for image_file in attachments_dir.iterdir():
+ if image_file.is_file() and image_file.suffix.lower() in image_extensions:
+ relative_path = image_file.relative_to(notes_path)
+ stat = image_file.stat()
+
+ images.append({
+ "name": image_file.name,
+ "path": str(relative_path.as_posix()),
+ "folder": str(relative_path.parent.as_posix()),
+ "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
+ "size": stat.st_size,
+ "type": "image"
+ })
+
+ return images
+
diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md
index d0a6022..994fe06 100644
--- a/documentation/FEATURES.md
+++ b/documentation/FEATURES.md
@@ -13,6 +13,11 @@
- **Mermaid diagrams** - Create flowcharts, sequence diagrams, and more (see [MERMAID.md](MERMAID.md))
- **HTML Export** - Export notes as standalone HTML files
+### Image Support
+- **Drag & drop upload** - Drop images from your file system directly into the editor
+- **Clipboard paste** - Paste images from clipboard with Ctrl+V
+- **Multiple formats** - Supports JPG, PNG, GIF, and WebP (max 10MB)
+
### Organization
- **Folder hierarchy** - Organize notes in nested folders
- **Drag & drop** - Move notes and folders effortlessly
@@ -24,7 +29,7 @@
### Internal Links
- **Wiki-style links** - `[[Note Name]]` syntax
-- **Drag to link** - Hold Ctrl and drag a note into the editor
+- **Drag to link** - Drag notes or images into the editor to insert links
- **Click to navigate** - Jump between notes seamlessly
- **External links** - Open in new tabs automatically
@@ -143,7 +148,6 @@ graph TD
| `Ctrl+Y` or `Ctrl+Shift+Z` | `Cmd+Y` or `Cmd+Shift+Z` | Redo |
| `F3` | `F3` | Next search match |
| `Shift+F3` | `Shift+F3` | Previous search match |
-| `Ctrl+Drag` | `Cmd+Drag` | Create internal link |
## 🚀 Performance
diff --git a/frontend/app.js b/frontend/app.js
index 849b33f..c0a7eaf 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -64,8 +64,9 @@ function noteApp() {
// Scroll sync state
isScrolling: false,
- // Drag state for internal linking
- draggedNoteForLink: null,
+ // Unified drag state
+ draggedItem: null, // { path: string, type: 'note' | 'image' }
+ dropTarget: null, // 'editor' | 'folder' | null
// Undo/Redo history
undoHistory: [],
@@ -95,6 +96,9 @@ function noteApp() {
// Mermaid state cache
lastMermaidTheme: null,
+ // Image viewer state
+ currentImage: '',
+
// DOM element cache (to avoid repeated querySelector calls)
_domCache: {
editor: null,
@@ -131,6 +135,8 @@ function noteApp() {
this.searchResults = [];
this.clearSearchHighlights();
}
+ } else if (e.state && e.state.imagePath) {
+ this.viewImage(e.state.imagePath, false); // false = don't update history
}
});
@@ -525,28 +531,46 @@ function noteApp() {
});
}
- // Then, render notes in this folder (after subfolders)
+ // Then, render notes and images in this folder (after subfolders)
if (folder.notes && folder.notes.length > 0) {
folder.notes.forEach(note => {
+ // Check if it's an image or a note
+ const isImage = note.type === 'image';
const isCurrentNote = this.currentNote === note.path;
+ const isCurrentImage = this.currentImage === note.path;
+ const isCurrent = isImage ? isCurrentImage : isCurrentNote;
+
+ // Different icon for images
+ const icon = isImage ? '🖼️' : '';
+
+ // Click handler
+ const clickHandler = isImage
+ ? `viewImage('${note.path.replace(/'/g, "\\'")}')`
+ : `loadNote('${note.path.replace(/'/g, "\\'")}')`;
+
+ // Delete handler
+ const deleteHandler = isImage
+ ? `deleteImage('${note.path.replace(/'/g, "\\'")}')`
+ : `deleteNote('${note.path.replace(/'/g, "\\'")}', '${note.name.replace(/'/g, "\\'")}')`;
+
html += `
-
${note.name}
+
${icon}${icon ? ' ' : ''}${note.name}
@@ -939,24 +947,27 @@
-
+
-
+
+ 🖼️
+
+