added image upload support!
This commit is contained in:
parent
7253a1988f
commit
4ff1f7e8bd
|
|
@ -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"""
|
||||
|
|
|
|||
140
backend/utils.py
140
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
342
frontend/app.js
342
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 += `
|
||||
<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 = ``;
|
||||
} 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 = ``;
|
||||
|
||||
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 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();
|
||||
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';
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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) -->
|
||||
|
|
|
|||
Loading…
Reference in New Issue