added image upload support!

This commit is contained in:
Gamosoft 2025-11-20 14:10:17 +01:00
parent 7253a1988f
commit 4ff1f7e8bd
5 changed files with 545 additions and 74 deletions

View File

@ -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"""

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]:
"""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

View File

@ -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

View File

@ -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 += `
<div
draggable="true"
x-data="{}"
@dragstart="onNoteDragStart('${note.path.replace(/'/g, "\\'")}', $event)"
@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"
style="${isCurrentNote ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'} cursor: pointer;"
@mouseover="if('${note.path}' !== currentNote) $el.style.backgroundColor='var(--bg-hover)'"
@mouseout="if('${note.path}' !== currentNote) $el.style.backgroundColor='transparent'"
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 && '${note.path}' !== currentImage) $el.style.backgroundColor='var(--bg-hover)'"
@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
@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"
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">
<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>
@ -667,25 +691,32 @@ function noteApp() {
// Drag and drop handlers
onNoteDragStart(notePath, event) {
// Check if Ctrl/Cmd is held for link mode
if (event.ctrlKey || event.metaKey) {
// Link mode: drag to create internal link
this.draggedNoteForLink = notePath;
event.dataTransfer.effectAllowed = 'link';
} else {
// Move mode: drag to move note
// Check if this is an image
const item = this.notes.find(n => n.path === notePath);
const isImage = item && item.type === 'image';
// Set unified drag state
this.draggedItem = {
path: notePath,
type: isImage ? 'image' : 'note'
};
// For notes, also set legacy draggedNote for folder move logic
if (!isImage) {
this.draggedNote = notePath;
event.dataTransfer.effectAllowed = 'move';
// Make drag image semi-transparent
if (event.target) {
event.target.style.opacity = '0.5';
}
}
event.dataTransfer.effectAllowed = 'all';
},
onNoteDragEnd() {
this.draggedNote = null;
this.draggedNoteForLink = null;
this.draggedItem = null;
this.dropTarget = null;
this.dragOverFolder = null;
// Reset opacity of all note items
document.querySelectorAll('.note-item').forEach(el => {
@ -695,7 +726,10 @@ function noteApp() {
// Handle dragover on editor to show cursor position
onEditorDragOver(event) {
if (!this.draggedNoteForLink) return;
if (!this.draggedItem) return;
event.preventDefault();
this.dropTarget = 'editor';
// Update cursor position as user drags over text
const textarea = event.target;
@ -716,27 +750,50 @@ function noteApp() {
// Handle dragenter on editor
onEditorDragEnter(event) {
if (!this.draggedNoteForLink) return;
if (!this.draggedItem) return;
event.preventDefault();
this.dropTarget = 'editor';
},
// Handle dragleave on editor
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
onEditorDrop(event) {
// Handle drop into editor to create internal link or upload image
async onEditorDrop(event) {
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;
const noteName = notePath.split('/').pop().replace('.md', '');
// Otherwise, handle note/image link drop from sidebar
if (!this.draggedItem) return;
// Create markdown link (URL-encode the path to handle spaces and special characters)
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
const link = `[${noteName}](${encodedPath})`;
const notePath = this.draggedItem.path;
const isImage = this.draggedItem.type === 'image';
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}](/data/${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
const textarea = event.target;
@ -755,7 +812,174 @@ function noteApp() {
// Trigger 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}](/data/${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
@ -808,6 +1032,7 @@ function noteApp() {
onFolderDragEnd() {
this.draggedFolder = null;
this.dropTarget = null;
this.dragOverFolder = null;
// Reset opacity of all folder items
document.querySelectorAll('.folder-item').forEach(el => {
@ -816,11 +1041,23 @@ function noteApp() {
},
async onFolderDrop(targetFolderPath) {
// Ignore if we're dropping into the editor
if (this.dropTarget === 'editor') {
return;
}
// Handle note drop into folder
if (this.draggedNote) {
const note = this.notes.find(n => n.path === this.draggedNote);
if (!note) return;
// Don't allow moving images to folders
if (note.type === 'image') {
this.draggedNote = null;
this.draggedItem = null;
return;
}
// Get note filename
const filename = note.path.split('/').pop();
const newPath = targetFolderPath ? `${targetFolderPath}/${filename}` : filename;
@ -901,6 +1138,7 @@ function noteApp() {
}
this.draggedFolder = null;
this.dropTarget = null;
}
},
@ -919,6 +1157,7 @@ function noteApp() {
window.history.replaceState({}, '', '/');
this.currentNote = '';
this.noteContent = '';
this.currentImage = '';
return;
}
throw new Error(`HTTP error! status: ${response.status}`);
@ -929,6 +1168,7 @@ function noteApp() {
this.currentNote = notePath;
this.noteContent = data.content;
this.currentNoteName = notePath.split('/').pop().replace('.md', '');
this.currentImage = ''; // Clear image viewer when loading a note
this.lastSaved = false;
// Initialize undo/redo history for this note
@ -1002,27 +1242,37 @@ function noteApp() {
const path = window.location.pathname;
// Skip if root path or static assets
if (path === '/' || path.startsWith('/static/') || path.startsWith('/api/')) {
if (path === '/' || path.startsWith('/static/') || path.startsWith('/api/') || path.startsWith('/data/')) {
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 notePath = decodedPath + '.md';
// Parse query string for search parameter
const urlParams = new URLSearchParams(window.location.search);
const searchParam = urlParams.get('search');
// Check if this is an image path (check if it exists in notes list as an image)
const matchedItem = this.notes.find(n => n.path === decodedPath);
// 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 (matchedItem && matchedItem.type === 'image') {
// It's an image, view it
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
if (searchParam) {
this.searchQuery = searchParam;
// Trigger search to populate results list
this.searchNotes();
// Parse query string for search parameter
const urlParams = new URLSearchParams(window.location.search);
const searchParam = urlParams.get('search');
// 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();
}
}
},

View File

@ -77,6 +77,14 @@
color: var(--text-primary, #111827);
}
/* Truncate utility for long text */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.bg-themed {
background-color: var(--bg-primary);
}
@ -929,7 +937,7 @@
@drop="if(draggedNote || draggedFolder) onFolderDrop('')"
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>
</div>
</div>
@ -939,24 +947,27 @@
<div x-html="renderFolderRecursive(folder, 0, true)"></div>
</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">
<div
draggable="true"
@dragstart="onNoteDragStart(note.path, $event)"
@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"
:style="(currentNote === note.path ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);') + (draggedNote || draggedFolder ? ' cursor: grabbing;' : ' cursor: pointer;')"
@mouseover="if(currentNote !== note.path) $el.style.backgroundColor='var(--bg-hover)'"
@mouseout="if(currentNote !== note.path) $el.style.backgroundColor='transparent'"
: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 && currentImage !== note.path) $el.style.backgroundColor='var(--bg-hover)'"
@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
@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"
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">
<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>
@ -996,7 +1007,7 @@
<!-- Main Content Area -->
<div class="flex-1 flex flex-col overflow-hidden">
<template x-if="!currentNote">
<template x-if="!currentNote && !currentImage">
<!-- Welcome Screen -->
<div class="flex-1 flex items-center justify-center" style="background-color: var(--bg-primary);">
<div class="text-center">
@ -1018,7 +1029,7 @@
</div>
</template>
<template x-if="currentNote">
<template x-if="currentNote || currentImage">
<!-- Editor Area -->
<div class="flex-1 flex flex-col overflow-hidden" style="background-color: var(--bg-primary);">
<!-- Toolbar -->
@ -1039,10 +1050,12 @@
</button>
<input
type="text"
x-model="currentNoteName"
@blur="renameNote()"
:value="currentNote ? currentNoteName : (currentImage ? currentImage.split('/').pop() : '')"
@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"
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 -->
<button
@ -1091,8 +1104,8 @@
<span x-show="!isSaving && lastSaved" class="text-xs" style="color: var(--success);">✓ Saved</span>
</div>
<div class="flex items-center space-x-2">
<!-- View Toggle -->
<div class="flex items-center space-x-2" x-show="currentNote">
<!-- View Toggle (only for notes) -->
<div class="flex rounded-lg p-1" style="background-color: var(--bg-tertiary);">
<button
@click="viewMode = 'edit'"
@ -1119,7 +1132,6 @@
<!-- Export HTML Button -->
<button
x-show="currentNote"
@click="exportToHTML()"
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);"
@ -1147,9 +1159,10 @@
@dragover.prevent="onEditorDragOver($event)"
@dragenter="onEditorDragEnter($event)"
@dragleave="onEditorDragLeave($event)"
:class="'flex-1 w-full p-6 resize-none focus:outline-none editor-textarea' + (draggedNoteForLink ? ' link-drop-target' : '')"
: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);' : '')"
:placeholder="draggedNoteForLink ? '💡 Drop here to create link at cursor position...' : 'Start writing in markdown...'"
@paste="handlePaste($event)"
:class="'flex-1 w-full p-6 resize-none focus:outline-none editor-textarea' + (draggedItem && dropTarget === 'editor' ? ' link-drop-target' : '')"
: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>
</div>
@ -1165,7 +1178,7 @@
<!-- Preview -->
<div
x-show="viewMode === 'preview' || viewMode === 'split'"
x-show="(viewMode === 'preview' || viewMode === 'split') && !currentImage"
class="overflow-y-auto overflow-x-hidden custom-scrollbar"
style="background-color: var(--bg-primary); min-height: 0;"
:style="viewMode === 'split' ? `width: ${100 - editorWidth}%;` : 'width: 100%;'"
@ -1176,6 +1189,20 @@
@click="handleInternalLink($event)"
></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="`/data/${currentImage}`"
:alt="currentImage.split('/').pop()"
style="max-width: 100%; max-height: 100%; object-fit: contain; padding: 2rem;"
/>
</div>
</template>
</div>
<!-- Stats Status Bar (only shows if plugin enabled) -->