Merge pull request #30 from gamosoft/features/add-images
- added image drag support - added image clipboard pasting support - removed need to hold CTRL while dragging
This commit is contained in:
commit
6b2ca6a8de
|
|
@ -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"""
|
||||||
|
|
|
||||||
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]:
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
332
frontend/app.js
332
frontend/app.js
|
|
@ -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: [],
|
||||||
|
|
@ -95,6 +96,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,
|
||||||
|
|
@ -131,6 +135,8 @@ function noteApp() {
|
||||||
this.searchResults = [];
|
this.searchResults = [];
|
||||||
this.clearSearchHighlights();
|
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) {
|
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>
|
||||||
|
|
@ -667,25 +691,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 => {
|
||||||
|
|
@ -695,7 +726,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;
|
||||||
|
|
@ -716,27 +750,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 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('/');
|
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
||||||
const link = `[${noteName}](${encodedPath})`;
|
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
|
// Insert at cursor position
|
||||||
const textarea = event.target;
|
const textarea = event.target;
|
||||||
|
|
@ -755,7 +812,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 = ``;
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -808,6 +1032,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 => {
|
||||||
|
|
@ -816,11 +1041,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;
|
||||||
|
|
@ -901,6 +1138,7 @@ function noteApp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.draggedFolder = null;
|
this.draggedFolder = null;
|
||||||
|
this.dropTarget = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -919,6 +1157,7 @@ function noteApp() {
|
||||||
window.history.replaceState({}, '', '/');
|
window.history.replaceState({}, '', '/');
|
||||||
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}`);
|
||||||
|
|
@ -929,6 +1168,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
|
||||||
|
|
@ -1006,8 +1246,17 @@ 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));
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
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';
|
const notePath = decodedPath + '.md';
|
||||||
|
|
||||||
// Parse query string for search parameter
|
// Parse query string for search parameter
|
||||||
|
|
@ -1024,6 +1273,7 @@ function noteApp() {
|
||||||
// Trigger search to populate results list
|
// Trigger search to populate results list
|
||||||
this.searchNotes();
|
this.searchNotes();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Highlight search term in editor and preview
|
// Highlight search term in editor and preview
|
||||||
|
|
@ -1605,24 +1855,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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -929,7 +937,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>
|
||||||
|
|
@ -939,24 +947,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>
|
||||||
|
|
@ -996,7 +1007,7 @@
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
<div class="flex-1 flex flex-col overflow-hidden">
|
<div class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
|
||||||
<template x-if="!currentNote">
|
<template x-if="!currentNote && !currentImage">
|
||||||
<!-- Welcome Screen -->
|
<!-- Welcome Screen -->
|
||||||
<div class="flex-1 flex items-center justify-center" style="background-color: var(--bg-primary);">
|
<div class="flex-1 flex items-center justify-center" style="background-color: var(--bg-primary);">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
|
|
@ -1018,7 +1029,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 -->
|
||||||
|
|
@ -1039,10 +1050,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
|
||||||
|
|
@ -1091,8 +1104,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'"
|
||||||
|
|
@ -1119,7 +1132,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);"
|
||||||
|
|
@ -1147,9 +1159,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>
|
||||||
|
|
||||||
|
|
@ -1165,7 +1178,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%;'"
|
||||||
|
|
@ -1176,6 +1189,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) -->
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue