merge: sync with upstream main

This commit is contained in:
sudo-Harshk 2025-11-20 20:38:45 +05:30
commit 790fec4f94
6 changed files with 642 additions and 90 deletions

View File

@ -32,6 +32,8 @@ from .utils import (
move_folder, move_folder,
rename_folder, rename_folder,
delete_folder, delete_folder,
save_uploaded_image,
validate_path_security,
) )
from .plugins import PluginManager from .plugins import PluginManager
from .themes import get_available_themes, get_theme_css from .themes import get_available_themes, get_theme_css
@ -433,6 +435,91 @@ async def create_new_folder(data: dict):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@api_router.get("/images/{image_path:path}")
async def get_image(image_path: str):
"""
Serve an image file with authentication protection.
"""
try:
notes_dir = config['storage']['notes_dir']
full_path = Path(notes_dir) / image_path
# Security: Validate path is within notes directory
if not validate_path_security(notes_dir, full_path):
raise HTTPException(status_code=403, detail="Access denied")
# Check file exists and is an image
if not full_path.exists() or not full_path.is_file():
raise HTTPException(status_code=404, detail="Image not found")
# Validate it's an image file
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
if full_path.suffix.lower() not in allowed_extensions:
raise HTTPException(status_code=400, detail="Not an image file")
# Return the file
return FileResponse(full_path)
except HTTPException:
raise
except Exception as e:
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") @api_router.post("/notes/move")
async def move_note_endpoint(data: dict): async def move_note_endpoint(data: dict):
"""Move a note to a different folder""" """Move a note to a different folder"""

View File

@ -157,23 +157,29 @@ def delete_folder(notes_dir: str, folder_path: str) -> bool:
def get_all_notes(notes_dir: str) -> List[Dict]: def get_all_notes(notes_dir: str) -> List[Dict]:
"""Recursively get all markdown notes""" """Recursively get all markdown notes and images"""
notes = [] items = []
notes_path = Path(notes_dir) notes_path = Path(notes_dir)
# Get all markdown notes
for md_file in notes_path.rglob("*.md"): for md_file in notes_path.rglob("*.md"):
relative_path = md_file.relative_to(notes_path) relative_path = md_file.relative_to(notes_path)
stat = md_file.stat() stat = md_file.stat()
notes.append({ items.append({
"name": md_file.stem, "name": md_file.stem,
"path": str(relative_path.as_posix()), "path": str(relative_path.as_posix()),
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "", "folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), "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]: 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 "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

View File

@ -81,6 +81,67 @@ Content-Type: application/json
} }
``` ```
## 🖼️ Images
### Get Image
```http
GET /api/images/{image_path}
```
Retrieve an image file with authentication protection.
**Example:**
```bash
curl http://localhost:8000/api/images/folder/_attachments/image-20240417093343.png
```
**Security Note:** This endpoint requires authentication and validates that:
- The image path is within the notes directory (prevents directory traversal)
- The file exists and is a valid image format
- The requesting user is authenticated (if auth is enabled)
### Upload Image
```http
POST /api/upload-image
Content-Type: multipart/form-data
file: <image file>
note_path: <path of note to attach to>
```
Upload an image file to the `_attachments` directory. Images are automatically organized per-folder and named with timestamps to prevent conflicts.
**Supported formats:** JPG, JPEG, PNG, GIF, WEBP
**Maximum size:** 10MB
**Response:**
```json
{
"success": true,
"path": "folder/_attachments/image-20240417093343.png",
"filename": "image-20240417093343.png",
"message": "Image uploaded successfully"
}
```
**Example (using curl):**
```bash
curl -X POST http://localhost:8000/api/upload-image \
-F "file=@/path/to/image.png" \
-F "note_path=folder/mynote.md"
```
**Windows PowerShell:**
```powershell
curl.exe -X POST http://localhost:8000/api/upload-image -F "file=@C:\path\to\image.png" -F "note_path=folder/mynote.md"
```
**Notes:**
- Images are stored in `_attachments` folders relative to the note's location
- Filenames are automatically timestamped (e.g., `image-20240417093343.png`)
- Images appear in the sidebar navigation and can be viewed/deleted directly
- Drag & drop images into the editor automatically uploads and inserts markdown
- All image access requires authentication when security is enabled
## 📁 Folders ## 📁 Folders
### Create Folder ### Create Folder
@ -93,6 +154,17 @@ Content-Type: application/json
} }
``` ```
### Delete Folder
```http
DELETE /api/folders/{folder_path}
```
Deletes a folder and all its contents.
**Example:**
```bash
curl -X DELETE http://localhost:8000/api/folders/Projects/Archive
```
### Move Folder ### Move Folder
```http ```http
POST /api/folders/move POST /api/folders/move

View File

@ -13,6 +13,11 @@
- **Mermaid diagrams** - Create flowcharts, sequence diagrams, and more (see [MERMAID.md](MERMAID.md)) - **Mermaid diagrams** - Create flowcharts, sequence diagrams, and more (see [MERMAID.md](MERMAID.md))
- **HTML Export** - Export notes as standalone HTML files - **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 ### Organization
- **Folder hierarchy** - Organize notes in nested folders - **Folder hierarchy** - Organize notes in nested folders
- **Drag & drop** - Move notes and folders effortlessly - **Drag & drop** - Move notes and folders effortlessly
@ -24,7 +29,7 @@
### Internal Links ### Internal Links
- **Wiki-style links** - `[[Note Name]]` syntax - **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 - **Click to navigate** - Jump between notes seamlessly
- **External links** - Open in new tabs automatically - **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 | | `Ctrl+Y` or `Ctrl+Shift+Z` | `Cmd+Y` or `Cmd+Shift+Z` | Redo |
| `F3` | `F3` | Next search match | | `F3` | `F3` | Next search match |
| `Shift+F3` | `Shift+F3` | Previous search match | | `Shift+F3` | `Shift+F3` | Previous search match |
| `Ctrl+Drag` | `Cmd+Drag` | Create internal link |
## 🚀 Performance ## 🚀 Performance

View File

@ -64,8 +64,9 @@ function noteApp() {
// Scroll sync state // Scroll sync state
isScrolling: false, isScrolling: false,
// Drag state for internal linking // Unified drag state
draggedNoteForLink: null, draggedItem: null, // { path: string, type: 'note' | 'image' }
dropTarget: null, // 'editor' | 'folder' | null
// Undo/Redo history // Undo/Redo history
undoHistory: [], undoHistory: [],
@ -230,6 +231,9 @@ function noteApp() {
// Mermaid state cache // Mermaid state cache
lastMermaidTheme: null, lastMermaidTheme: null,
// Image viewer state
currentImage: '',
// DOM element cache (to avoid repeated querySelector calls) // DOM element cache (to avoid repeated querySelector calls)
_domCache: { _domCache: {
editor: null, editor: null,
@ -684,28 +688,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) { if (folder.notes && folder.notes.length > 0) {
folder.notes.forEach(note => { 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 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 += ` html += `
<div <div
draggable="true" draggable="true"
x-data="{}" x-data="{}"
@dragstart="onNoteDragStart('${note.path.replace(/'/g, "\\'")}', $event)" @dragstart="onNoteDragStart('${note.path.replace(/'/g, "\\'")}', $event)"
@dragend="onNoteDragEnd()" @dragend="onNoteDragEnd()"
@click="loadNote('${note.path.replace(/'/g, "\\'")}')" @click="${clickHandler}"
class="note-item px-3 py-2 mb-1 text-sm rounded relative border-2 border-transparent" class="note-item px-3 py-2 mb-1 text-sm rounded relative border-2 border-transparent"
style="${isCurrentNote ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'} cursor: pointer;" style="${isCurrent ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'} ${isImage ? 'opacity: 0.85;' : ''} cursor: pointer;"
@mouseover="if('${note.path}' !== currentNote) $el.style.backgroundColor='var(--bg-hover)'" @mouseover="if('${note.path}' !== currentNote && '${note.path}' !== currentImage) $el.style.backgroundColor='var(--bg-hover)'"
@mouseout="if('${note.path}' !== currentNote) $el.style.backgroundColor='transparent'" @mouseout="if('${note.path}' !== currentNote && '${note.path}' !== currentImage) $el.style.backgroundColor='transparent'"
> >
<span class="truncate">${note.name}</span> <span class="truncate" style="display: block; padding-right: 30px;">${icon}${icon ? ' ' : ''}${note.name}</span>
<button <button
@click.stop="deleteNote('${note.path.replace(/'/g, "\\'")}', '${note.name.replace(/'/g, "\\'")}')" @click.stop="${deleteHandler}"
class="note-delete-btn absolute right-2 top-1/2 transform -translate-y-1/2 px-1 py-0.5 text-xs rounded hover:brightness-110 transition-opacity" class="note-delete-btn absolute right-2 top-1/2 transform -translate-y-1/2 px-1 py-0.5 text-xs rounded hover:brightness-110 transition-opacity"
style="opacity: 0; color: var(--error);" style="opacity: 0; color: var(--error);"
title="Delete note" title="${isImage ? 'Delete image' : 'Delete note'}"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
@ -826,25 +848,32 @@ function noteApp() {
// Drag and drop handlers // Drag and drop handlers
onNoteDragStart(notePath, event) { onNoteDragStart(notePath, event) {
// Check if Ctrl/Cmd is held for link mode // Check if this is an image
if (event.ctrlKey || event.metaKey) { const item = this.notes.find(n => n.path === notePath);
// Link mode: drag to create internal link const isImage = item && item.type === 'image';
this.draggedNoteForLink = notePath;
event.dataTransfer.effectAllowed = 'link'; // Set unified drag state
} else { this.draggedItem = {
// Move mode: drag to move note path: notePath,
type: isImage ? 'image' : 'note'
};
// For notes, also set legacy draggedNote for folder move logic
if (!isImage) {
this.draggedNote = notePath; this.draggedNote = notePath;
event.dataTransfer.effectAllowed = 'move';
// Make drag image semi-transparent // Make drag image semi-transparent
if (event.target) { if (event.target) {
event.target.style.opacity = '0.5'; event.target.style.opacity = '0.5';
} }
} }
event.dataTransfer.effectAllowed = 'all';
}, },
onNoteDragEnd() { onNoteDragEnd() {
this.draggedNote = null; this.draggedNote = null;
this.draggedNoteForLink = null; this.draggedItem = null;
this.dropTarget = null;
this.dragOverFolder = null; this.dragOverFolder = null;
// Reset opacity of all note items // Reset opacity of all note items
document.querySelectorAll('.note-item').forEach(el => { document.querySelectorAll('.note-item').forEach(el => {
@ -854,7 +883,10 @@ function noteApp() {
// Handle dragover on editor to show cursor position // Handle dragover on editor to show cursor position
onEditorDragOver(event) { onEditorDragOver(event) {
if (!this.draggedNoteForLink) return; if (!this.draggedItem) return;
event.preventDefault();
this.dropTarget = 'editor';
// Update cursor position as user drags over text // Update cursor position as user drags over text
const textarea = event.target; const textarea = event.target;
@ -875,27 +907,50 @@ function noteApp() {
// Handle dragenter on editor // Handle dragenter on editor
onEditorDragEnter(event) { onEditorDragEnter(event) {
if (!this.draggedNoteForLink) return; if (!this.draggedItem) return;
event.preventDefault(); event.preventDefault();
this.dropTarget = 'editor';
}, },
// Handle dragleave on editor // Handle dragleave on editor
onEditorDragLeave(event) { onEditorDragLeave(event) {
// Note: draggedNoteForLink will be cleared on dragend anyway // Only clear dropTarget if we're actually leaving the editor
// (not just moving between child elements)
if (event.target.tagName === 'TEXTAREA') {
this.dropTarget = null;
}
}, },
// Handle drop into editor to create internal link // Handle drop into editor to create internal link or upload image
onEditorDrop(event) { async onEditorDrop(event) {
event.preventDefault(); event.preventDefault();
this.dropTarget = null;
if (!this.draggedNoteForLink) return; // Check if files are being dropped (images from file system)
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
await this.handleImageDrop(event);
return;
}
const notePath = this.draggedNoteForLink; // Otherwise, handle note/image link drop from sidebar
const noteName = notePath.split('/').pop().replace('.md', ''); if (!this.draggedItem) return;
// Create markdown link (URL-encode the path to handle spaces and special characters) const notePath = this.draggedItem.path;
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/'); const isImage = this.draggedItem.type === 'image';
const link = `[${noteName}](${encodedPath})`;
let link;
if (isImage) {
// For images, insert image markdown
const filename = notePath.split('/').pop().replace(/\.[^/.]+$/, ''); // Remove extension
// URL-encode the path to handle spaces and special characters
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
link = `![${filename}](/api/images/${encodedPath})`;
} else {
// For notes, insert note link
const noteName = notePath.split('/').pop().replace('.md', '');
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
link = `[${noteName}](${encodedPath})`;
}
// Insert at cursor position // Insert at cursor position
const textarea = event.target; const textarea = event.target;
@ -914,7 +969,174 @@ function noteApp() {
// Trigger autosave // Trigger autosave
this.autoSave(); this.autoSave();
this.draggedNoteForLink = null; this.draggedItem = null;
},
// Handle image files dropped into editor
async handleImageDrop(event) {
if (!this.currentNote) {
alert('Please open a note first before uploading images.');
return;
}
const files = Array.from(event.dataTransfer.files);
const imageFiles = files.filter(file => {
const type = file.type.toLowerCase();
return type.startsWith('image/') &&
['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'].includes(type);
});
if (imageFiles.length === 0) {
alert('No valid image files found. Supported formats: JPG, PNG, GIF, WEBP');
return;
}
const textarea = event.target;
const cursorPos = textarea.selectionStart || 0;
// Upload each image
for (const file of imageFiles) {
try {
const imagePath = await this.uploadImage(file, this.currentNote);
if (imagePath) {
this.insertImageMarkdown(imagePath, file.name, cursorPos);
}
} catch (error) {
ErrorHandler.handle(`upload image ${file.name}`, error);
}
}
},
// Upload an image file
async uploadImage(file, notePath) {
const formData = new FormData();
formData.append('file', file);
formData.append('note_path', notePath);
try {
const response = await fetch('/api/upload-image', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Upload failed');
}
const data = await response.json();
return data.path;
} catch (error) {
throw error;
}
},
// Insert image markdown at cursor position
insertImageMarkdown(imagePath, altText, cursorPos) {
const filename = altText.replace(/\.[^/.]+$/, ''); // Remove extension
// URL-encode the path to handle spaces and special characters
const encodedPath = imagePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
const markdown = `![${filename}](/api/images/${encodedPath})`;
const textBefore = this.noteContent.substring(0, cursorPos);
const textAfter = this.noteContent.substring(cursorPos);
this.noteContent = textBefore + markdown + '\n' + textAfter;
// Trigger autosave
this.autoSave();
// Reload notes to show the new image in sidebar
this.loadNotes();
},
// Handle paste event for clipboard images
async handlePaste(event) {
if (!this.currentNote) return;
const items = event.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
event.preventDefault();
const blob = item.getAsFile();
if (blob) {
try {
const textarea = event.target;
const cursorPos = textarea.selectionStart || 0;
// Create a filename based on timestamp (format: YYYYMMDDHHMMSS)
const now = new Date();
const timestamp = now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0') +
String(now.getSeconds()).padStart(2, '0');
const ext = item.type.split('/')[1] || 'png';
const filename = `pasted-image-${timestamp}.${ext}`;
// Create a File from the blob
const file = new File([blob], filename, { type: item.type });
const imagePath = await this.uploadImage(file, this.currentNote);
if (imagePath) {
this.insertImageMarkdown(imagePath, filename, cursorPos);
}
} catch (error) {
ErrorHandler.handle('paste image', error);
}
}
break; // Only handle first image
}
}
},
// View an image in the main pane
viewImage(imagePath, updateHistory = true) {
this.currentNote = '';
this.currentNoteName = '';
this.noteContent = '';
this.currentImage = imagePath;
this.viewMode = 'preview'; // Use preview mode to show image
// Update browser URL
if (updateHistory) {
// Encode each path segment to handle special characters
const encodedPath = imagePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
window.history.pushState(
{ imagePath: imagePath },
'',
`/${encodedPath}`
);
}
},
// Delete an image
async deleteImage(imagePath) {
const filename = imagePath.split('/').pop();
if (!confirm(`Delete image "${filename}"?`)) return;
try {
const response = await fetch(`/api/notes/${encodeURIComponent(imagePath)}`, {
method: 'DELETE'
});
if (response.ok) {
await this.loadNotes(); // Refresh tree
// Clear viewer if deleting currently viewed image
if (this.currentImage === imagePath) {
this.currentImage = '';
}
} else {
throw new Error('Failed to delete image');
}
} catch (error) {
ErrorHandler.handle('delete image', error);
}
}, },
// Handle clicks on internal links in preview // Handle clicks on internal links in preview
@ -967,6 +1189,7 @@ function noteApp() {
onFolderDragEnd() { onFolderDragEnd() {
this.draggedFolder = null; this.draggedFolder = null;
this.dropTarget = null;
this.dragOverFolder = null; this.dragOverFolder = null;
// Reset opacity of all folder items // Reset opacity of all folder items
document.querySelectorAll('.folder-item').forEach(el => { document.querySelectorAll('.folder-item').forEach(el => {
@ -975,11 +1198,23 @@ function noteApp() {
}, },
async onFolderDrop(targetFolderPath) { async onFolderDrop(targetFolderPath) {
// Ignore if we're dropping into the editor
if (this.dropTarget === 'editor') {
return;
}
// Handle note drop into folder // Handle note drop into folder
if (this.draggedNote) { if (this.draggedNote) {
const note = this.notes.find(n => n.path === this.draggedNote); const note = this.notes.find(n => n.path === this.draggedNote);
if (!note) return; if (!note) return;
// Don't allow moving images to folders
if (note.type === 'image') {
this.draggedNote = null;
this.draggedItem = null;
return;
}
// Get note filename // Get note filename
const filename = note.path.split('/').pop(); const filename = note.path.split('/').pop();
const newPath = targetFolderPath ? `${targetFolderPath}/${filename}` : filename; const newPath = targetFolderPath ? `${targetFolderPath}/${filename}` : filename;
@ -1060,6 +1295,7 @@ function noteApp() {
} }
this.draggedFolder = null; this.draggedFolder = null;
this.dropTarget = null;
} }
}, },
@ -1078,6 +1314,7 @@ function noteApp() {
window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/'); window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/');
this.currentNote = ''; this.currentNote = '';
this.noteContent = ''; this.noteContent = '';
this.currentImage = '';
return; return;
} }
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
@ -1088,6 +1325,7 @@ function noteApp() {
this.currentNote = notePath; this.currentNote = notePath;
this.noteContent = data.content; this.noteContent = data.content;
this.currentNoteName = notePath.split('/').pop().replace('.md', ''); this.currentNoteName = notePath.split('/').pop().replace('.md', '');
this.currentImage = ''; // Clear image viewer when loading a note
this.lastSaved = false; this.lastSaved = false;
// Initialize undo/redo history for this note // Initialize undo/redo history for this note
@ -1169,23 +1407,33 @@ function noteApp() {
return; return;
} }
// Remove leading slash, decode URL encoding (e.g., %20 -> space), and add .md extension // Remove leading slash and decode URL encoding (e.g., %20 -> space)
const decodedPath = decodeURIComponent(path.substring(1)); const decodedPath = decodeURIComponent(path.substring(1));
const notePath = decodedPath + '.md';
// Parse query string for search parameter // Check if this is an image path (check if it exists in notes list as an image)
const urlParams = new URLSearchParams(window.location.search); const matchedItem = this.notes.find(n => n.path === decodedPath);
const searchParam = urlParams.get('search');
// Try to load the note directly - the backend will handle 404 if it doesn't exist if (matchedItem && matchedItem.type === 'image') {
// This is more robust than checking the frontend notes list // It's an image, view it
this.loadNote(notePath, false, searchParam || ''); this.viewImage(decodedPath, false); // false = don't update history (we're already at this URL)
} else {
// It's a note, add .md extension and load it
const notePath = decodedPath + '.md';
// If there's a search parameter, populate the search box and trigger search // Parse query string for search parameter
if (searchParam) { const urlParams = new URLSearchParams(window.location.search);
this.searchQuery = searchParam; const searchParam = urlParams.get('search');
// Trigger search to populate results list
this.searchNotes(); // Try to load the note directly - the backend will handle 404 if it doesn't exist
// This is more robust than checking the frontend notes list
this.loadNote(notePath, false, searchParam || '');
// If there's a search parameter, populate the search box and trigger search
if (searchParam) {
this.searchQuery = searchParam;
// Trigger search to populate results list
this.searchNotes();
}
} }
}, },
@ -1769,24 +2017,8 @@ function noteApp() {
async deleteCurrentNote() { async deleteCurrentNote() {
if (!this.currentNote) return; if (!this.currentNote) return;
if (!confirm(`Delete "${this.currentNoteName}"?`)) return; // Just call deleteNote with current note details
await this.deleteNote(this.currentNote, this.currentNoteName);
try {
const response = await fetch(`/api/notes/${this.currentNote}`, {
method: 'DELETE'
});
if (response.ok) {
this.currentNote = '';
this.noteContent = '';
this.currentNoteName = '';
await this.loadNotes();
} else {
ErrorHandler.handle('delete note', new Error('Server returned error'));
}
} catch (error) {
ErrorHandler.handle('delete note', error);
}
}, },
// Delete any note from sidebar // Delete any note from sidebar

View File

@ -77,6 +77,14 @@
color: var(--text-primary, #111827); color: var(--text-primary, #111827);
} }
/* Truncate utility for long text */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.bg-themed { .bg-themed {
background-color: var(--bg-primary); background-color: var(--bg-primary);
} }
@ -934,7 +942,7 @@
@drop="if(draggedNote || draggedFolder) onFolderDrop('')" @drop="if(draggedNote || draggedFolder) onFolderDrop('')"
style="height: 28px; line-height: 20px; display: flex; align-items: center; justify-content: center;" style="height: 28px; line-height: 20px; display: flex; align-items: center; justify-content: center;"
> >
<span x-show="!draggedNote && !draggedFolder">💡 Drag=Move | <kbd style="padding: 1px 4px; border-radius: 3px; background: var(--bg-tertiary); font-size: 10px;">Ctrl</kbd>+Drag=Link</span> <span x-show="!draggedNote && !draggedFolder && !draggedItem">💡 Drag=Move </span>
<span x-show="draggedNote || draggedFolder">📂 Root folder</span> <span x-show="draggedNote || draggedFolder">📂 Root folder</span>
</div> </div>
</div> </div>
@ -944,24 +952,27 @@
<div x-html="renderFolderRecursive(folder, 0, true)"></div> <div x-html="renderFolderRecursive(folder, 0, true)"></div>
</template> </template>
<!-- Root notes (no folder) - shown after folders --> <!-- Root notes and images (no folder) - shown after folders -->
<template x-for="note in (folderTree.__root__ && folderTree.__root__.notes ? folderTree.__root__.notes : [])" :key="note.path"> <template x-for="note in (folderTree.__root__ && folderTree.__root__.notes ? folderTree.__root__.notes : [])" :key="note.path">
<div <div
draggable="true" draggable="true"
@dragstart="onNoteDragStart(note.path, $event)" @dragstart="onNoteDragStart(note.path, $event)"
@dragend="onNoteDragEnd()" @dragend="onNoteDragEnd()"
@click="loadNote(note.path)" @click="note.type === 'image' ? viewImage(note.path) : loadNote(note.path)"
class="note-item px-3 py-2 mb-1 text-sm rounded relative border-2 border-transparent" class="note-item px-3 py-2 mb-1 text-sm rounded relative border-2 border-transparent"
:style="(currentNote === note.path ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);') + (draggedNote || draggedFolder ? ' cursor: grabbing;' : ' cursor: pointer;')" :style="((note.type === 'image' ? currentImage === note.path : currentNote === note.path) ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);') + (note.type === 'image' ? ' opacity: 0.85;' : '') + (draggedNote || draggedFolder ? ' cursor: grabbing;' : ' cursor: pointer;')"
@mouseover="if(currentNote !== note.path) $el.style.backgroundColor='var(--bg-hover)'" @mouseover="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='var(--bg-hover)'"
@mouseout="if(currentNote !== note.path) $el.style.backgroundColor='transparent'" @mouseout="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='transparent'"
> >
<span class="truncate" x-text="note.name"></span> <span class="truncate" style="display: block; padding-right: 30px;">
<template x-if="note.type === 'image'">🖼️ </template>
<span x-text="note.name"></span>
</span>
<button <button
@click.stop="deleteNote(note.path, note.name)" @click.stop="note.type === 'image' ? deleteImage(note.path) : deleteNote(note.path, note.name)"
class="note-delete-btn absolute right-2 top-1/2 transform -translate-y-1/2 px-1 py-0.5 text-xs rounded hover:brightness-110 transition-opacity" class="note-delete-btn absolute right-2 top-1/2 transform -translate-y-1/2 px-1 py-0.5 text-xs rounded hover:brightness-110 transition-opacity"
style="opacity: 0; color: var(--error);" style="opacity: 0; color: var(--error);"
title="Delete note" :title="note.type === 'image' ? 'Delete image' : 'Delete note'"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
@ -1164,7 +1175,7 @@
</div> </div>
</template> </template>
<template x-if="currentNote"> <template x-if="currentNote || currentImage">
<!-- Editor Area --> <!-- Editor Area -->
<div class="flex-1 flex flex-col overflow-hidden" style="background-color: var(--bg-primary);"> <div class="flex-1 flex flex-col overflow-hidden" style="background-color: var(--bg-primary);">
<!-- Toolbar --> <!-- Toolbar -->
@ -1185,10 +1196,12 @@
</button> </button>
<input <input
type="text" type="text"
x-model="currentNoteName" :value="currentNote ? currentNoteName : (currentImage ? currentImage.split('/').pop() : '')"
@blur="renameNote()" @input="if (currentNote) currentNoteName = $event.target.value"
@blur="if (currentNote) renameNote()"
:readonly="!currentNote"
class="text-xl font-semibold border-none focus:outline-none focus:ring-2 rounded px-3 py-1.5" class="text-xl font-semibold border-none focus:outline-none focus:ring-2 rounded px-3 py-1.5"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary); min-width: 150px; max-width: 300px;" :style="'background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary); min-width: 150px; max-width: 300px;' + (!currentNote ? ' cursor: default; opacity: 0.9;' : '')"
> >
<!-- Undo Button --> <!-- Undo Button -->
<button <button
@ -1237,8 +1250,8 @@
<span x-show="!isSaving && lastSaved" class="text-xs" style="color: var(--success);">✓ Saved</span> <span x-show="!isSaving && lastSaved" class="text-xs" style="color: var(--success);">✓ Saved</span>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2" x-show="currentNote">
<!-- View Toggle --> <!-- View Toggle (only for notes) -->
<div class="flex rounded-lg p-1" style="background-color: var(--bg-tertiary);"> <div class="flex rounded-lg p-1" style="background-color: var(--bg-tertiary);">
<button <button
@click="viewMode = 'edit'" @click="viewMode = 'edit'"
@ -1265,7 +1278,6 @@
<!-- Export HTML Button --> <!-- Export HTML Button -->
<button <button
x-show="currentNote"
@click="exportToHTML()" @click="exportToHTML()"
class="px-2 md:px-3 py-1 md:py-1.5 text-xs md:text-sm rounded transition hover:opacity-80" class="px-2 md:px-3 py-1 md:py-1.5 text-xs md:text-sm rounded transition hover:opacity-80"
style="background-color: var(--accent-secondary); color: var(--text-primary);" style="background-color: var(--accent-secondary); color: var(--text-primary);"
@ -1293,9 +1305,10 @@
@dragover.prevent="onEditorDragOver($event)" @dragover.prevent="onEditorDragOver($event)"
@dragenter="onEditorDragEnter($event)" @dragenter="onEditorDragEnter($event)"
@dragleave="onEditorDragLeave($event)" @dragleave="onEditorDragLeave($event)"
:class="'flex-1 w-full p-6 resize-none focus:outline-none editor-textarea' + (draggedNoteForLink ? ' link-drop-target' : '')" @paste="handlePaste($event)"
:style="'background-color: var(--bg-primary); color: var(--text-primary);' + (draggedNoteForLink ? ' outline: 2px dashed var(--accent-primary); outline-offset: -2px; box-shadow: 0 0 20px var(--accent-light);' : '')" :class="'flex-1 w-full p-6 resize-none focus:outline-none editor-textarea' + (draggedItem && dropTarget === 'editor' ? ' link-drop-target' : '')"
:placeholder="draggedNoteForLink ? '💡 Drop here to create link at cursor position...' : 'Start writing in markdown...'" :style="'background-color: var(--bg-primary); color: var(--text-primary);' + (draggedItem && dropTarget === 'editor' ? ' outline: 2px dashed var(--accent-primary); outline-offset: -2px; box-shadow: 0 0 20px var(--accent-light);' : '')"
:placeholder="draggedItem && dropTarget === 'editor' ? '💡 Drop here to insert at cursor position...' : 'Start writing in markdown...'"
></textarea> ></textarea>
</div> </div>
@ -1311,7 +1324,7 @@
<!-- Preview --> <!-- Preview -->
<div <div
x-show="viewMode === 'preview' || viewMode === 'split'" x-show="(viewMode === 'preview' || viewMode === 'split') && !currentImage"
class="overflow-y-auto overflow-x-hidden custom-scrollbar" class="overflow-y-auto overflow-x-hidden custom-scrollbar"
style="background-color: var(--bg-primary); min-height: 0;" style="background-color: var(--bg-primary); min-height: 0;"
:style="viewMode === 'split' ? `width: ${100 - editorWidth}%;` : 'width: 100%;'" :style="viewMode === 'split' ? `width: ${100 - editorWidth}%;` : 'width: 100%;'"
@ -1322,6 +1335,20 @@
@click="handleInternalLink($event)" @click="handleInternalLink($event)"
></div> ></div>
</div> </div>
<!-- Image Viewer -->
<template x-if="currentImage">
<div
class="flex-1 flex items-center justify-center overflow-auto"
style="background-color: var(--bg-primary); min-height: 0;"
>
<img
:src="`/api/images/${currentImage}`"
:alt="currentImage.split('/').pop()"
style="max-width: 100%; max-height: 100%; object-fit: contain; padding: 2rem;"
/>
</div>
</template>
</div> </div>
<!-- Stats Status Bar (only shows if plugin enabled) --> <!-- Stats Status Bar (only shows if plugin enabled) -->