markdown preview
This commit is contained in:
23
README.md
23
README.md
@@ -124,6 +124,29 @@ To build a standalone binary:
|
|||||||
```bash
|
```bash
|
||||||
go build -o gobsidian cmd/main.go
|
go build -o gobsidian cmd/main.go
|
||||||
```
|
```
|
||||||
|
## Image storing trying to follow Obsidian settings
|
||||||
|
Image storing modes:
|
||||||
|
|
||||||
|
1. Mode 1 (Root directory):
|
||||||
|
- Images are stored directly in NOTES_DIR root
|
||||||
|
- In .md file: just filename like `![[image.png]]`
|
||||||
|
- When showing: serve from NOTES_DIR root
|
||||||
|
|
||||||
|
2. Mode 2 (Specific folder):
|
||||||
|
- Images stored in IMAGE_STORAGE_PATH
|
||||||
|
- In .md file: just filename like `![[image.png]]`
|
||||||
|
- When showing: serve from IMAGE_STORAGE_PATH
|
||||||
|
|
||||||
|
3. Mode 3 (Same as note):
|
||||||
|
- Images stored in same directory as the .md file
|
||||||
|
- In .md file: just filename like `![[image.png]]` (since it's in same dir)
|
||||||
|
- When showing: serve from note's directory
|
||||||
|
|
||||||
|
4. Mode 4 (Subfolder under note):
|
||||||
|
- Images stored in IMAGE_SUBFOLDER_NAME under note's directory
|
||||||
|
- In .md file: `![[<IMAGE_SUBFOLDER_NAME>/image.png]]`
|
||||||
|
- When showing: serve from note's directory/IMAGE_SUBFOLDER_NAME
|
||||||
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ func (h *Handlers) CreateNotePageHandler(c *gin.Context) {
|
|||||||
"active_path": utils.GetActivePath(folderPath),
|
"active_path": utils.GetActivePath(folderPath),
|
||||||
"current_note": nil,
|
"current_note": nil,
|
||||||
"breadcrumbs": utils.GenerateBreadcrumbs(folderPath),
|
"breadcrumbs": utils.GenerateBreadcrumbs(folderPath),
|
||||||
|
"image_storage_mode": h.config.ImageStorageMode,
|
||||||
|
"image_subfolder_name": h.config.ImageSubfolderName,
|
||||||
"ContentTemplate": "create_content",
|
"ContentTemplate": "create_content",
|
||||||
"ScriptsTemplate": "create_scripts",
|
"ScriptsTemplate": "create_scripts",
|
||||||
"Page": "create",
|
"Page": "create",
|
||||||
@@ -204,6 +206,8 @@ func (h *Handlers) EditNotePageHandler(c *gin.Context) {
|
|||||||
"active_path": utils.GetActivePath(folderPath),
|
"active_path": utils.GetActivePath(folderPath),
|
||||||
"current_note": notePath,
|
"current_note": notePath,
|
||||||
"breadcrumbs": utils.GenerateBreadcrumbs(folderPath),
|
"breadcrumbs": utils.GenerateBreadcrumbs(folderPath),
|
||||||
|
"image_storage_mode": h.config.ImageStorageMode,
|
||||||
|
"image_subfolder_name": h.config.ImageSubfolderName,
|
||||||
"ContentTemplate": "edit_content",
|
"ContentTemplate": "edit_content",
|
||||||
"ScriptsTemplate": "edit_scripts",
|
"ScriptsTemplate": "edit_scripts",
|
||||||
"Page": "edit",
|
"Page": "edit",
|
||||||
|
|||||||
@@ -6,7 +6,17 @@
|
|||||||
<div class="max-w-4xl mx-auto p-6">
|
<div class="max-w-4xl mx-auto p-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl font-bold text-white mb-4">Create New Note</h1>
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h1 class="text-3xl font-bold text-white">Create New Note</h1>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<button id="toggle-preview" type="button" class="btn-secondary">
|
||||||
|
<i class="fas fa-eye mr-2"></i>Preview
|
||||||
|
</button>
|
||||||
|
<button type="submit" form="create-form" class="btn-primary">
|
||||||
|
<i class="fas fa-save mr-2"></i>Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{if .folder_path}}
|
{{if .folder_path}}
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-400">
|
||||||
<i class="fas fa-folder mr-2"></i>
|
<i class="fas fa-folder mr-2"></i>
|
||||||
@@ -29,12 +39,16 @@
|
|||||||
<p class="text-xs text-gray-500 mt-1">The .md extension will be added automatically</p>
|
<p class="text-xs text-gray-500 mt-1">The .md extension will be added automatically</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content Editor -->
|
<!-- Content Editor with Live Preview -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label for="content" class="block text-sm font-medium text-gray-300 mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
Content
|
<label for="content" class="block text-sm font-medium text-gray-300">
|
||||||
</label>
|
Content
|
||||||
<textarea id="content" name="content" rows="20"
|
</label>
|
||||||
|
<div class="text-xs text-gray-400">Live Preview</div>
|
||||||
|
</div>
|
||||||
|
<div id="editor-grid" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<textarea id="content" name="content" rows="20"
|
||||||
class="editor-textarea"
|
class="editor-textarea"
|
||||||
placeholder="# Your Note Title
|
placeholder="# Your Note Title
|
||||||
|
|
||||||
@@ -59,16 +73,11 @@ console.log('Hello, World!');
|
|||||||
|--------|------|-----|
|
|--------|------|-----|
|
||||||
| Cell 1 | Cell 2 | Cell 3 |
|
| Cell 1 | Cell 2 | Cell 3 |
|
||||||
"></textarea>
|
"></textarea>
|
||||||
|
<div id="live-preview" class="prose prose-invert prose-dark bg-slate-800 border border-gray-700 rounded-lg p-4 overflow-y-auto hidden" style="min-height: 20rem;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<a href="{{if .folder_path}}/folder/{{.folder_path}}{{else}}/{{end}}" class="btn-secondary">
|
|
||||||
<i class="fas fa-arrow-left mr-2"></i>Cancel
|
|
||||||
</a>
|
|
||||||
<button type="submit" class="btn-primary">
|
|
||||||
<i class="fas fa-save mr-2"></i>Create Note
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -80,6 +89,73 @@ console.log('Hello, World!');
|
|||||||
const createForm = document.getElementById('create-form');
|
const createForm = document.getElementById('create-form');
|
||||||
const titleInput = document.getElementById('title');
|
const titleInput = document.getElementById('title');
|
||||||
const contentTextarea = document.getElementById('content');
|
const contentTextarea = document.getElementById('content');
|
||||||
|
const livePreview = document.getElementById('live-preview');
|
||||||
|
const editorGrid = document.getElementById('editor-grid');
|
||||||
|
const togglePreviewBtn = document.getElementById('toggle-preview');
|
||||||
|
|
||||||
|
const imageStorageMode = {{.image_storage_mode}};
|
||||||
|
const imageSubfolderName = "{{.image_subfolder_name}}";
|
||||||
|
const currentFolderPath = "{{.folder_path}}";
|
||||||
|
|
||||||
|
// Preview state (persist for create page)
|
||||||
|
const PREVIEW_STATE_KEY = 'previewEnabled:create';
|
||||||
|
let previewEnabled = localStorage.getItem(PREVIEW_STATE_KEY) === 'true';
|
||||||
|
|
||||||
|
// Load Marked for client-side markdown rendering
|
||||||
|
(function ensureMarked() {
|
||||||
|
if (window.marked) return;
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = 'https://cdn.jsdelivr.net/npm/marked/marked.min.js';
|
||||||
|
document.head.appendChild(s);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function buildImageURL(filename) {
|
||||||
|
if (imageStorageMode === 2) {
|
||||||
|
return `/serve_stored_image/${filename}`;
|
||||||
|
}
|
||||||
|
let path = filename;
|
||||||
|
if (imageStorageMode === 3 && currentFolderPath) {
|
||||||
|
path = `${currentFolderPath}/${filename}`;
|
||||||
|
} else if (imageStorageMode === 4 && currentFolderPath) {
|
||||||
|
path = `${currentFolderPath}/${imageSubfolderName}/${filename}`;
|
||||||
|
}
|
||||||
|
return `/serve_attached_image/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformObsidianEmbeds(md) {
|
||||||
|
return (md || '').replace(/!\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (m, file, alt) => {
|
||||||
|
const filename = file.trim();
|
||||||
|
const url = buildImageURL(filename);
|
||||||
|
const altText = (alt || filename).trim();
|
||||||
|
return ``;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreviewState() {
|
||||||
|
if (!livePreview || !editorGrid) return;
|
||||||
|
if (previewEnabled) {
|
||||||
|
livePreview.classList.remove('hidden');
|
||||||
|
editorGrid.classList.add('lg:grid-cols-2');
|
||||||
|
renderPreview();
|
||||||
|
togglePreviewBtn && (togglePreviewBtn.innerHTML = '<i class="fas fa-eye-slash mr-2"></i>Hide Preview');
|
||||||
|
} else {
|
||||||
|
livePreview.classList.add('hidden');
|
||||||
|
editorGrid.classList.remove('lg:grid-cols-2');
|
||||||
|
togglePreviewBtn && (togglePreviewBtn.innerHTML = '<i class="fas fa-eye mr-2"></i>Preview');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render preview
|
||||||
|
function renderPreview() {
|
||||||
|
if (!previewEnabled) return;
|
||||||
|
if (!livePreview || !window.marked) return;
|
||||||
|
const transformed = transformObsidianEmbeds(contentTextarea.value || '');
|
||||||
|
livePreview.innerHTML = marked.parse(transformed);
|
||||||
|
// Highlight code blocks
|
||||||
|
livePreview.querySelectorAll('pre code').forEach(block => {
|
||||||
|
try { hljs.highlightElement(block); } catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
createForm.addEventListener('submit', function(e) {
|
createForm.addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -122,8 +198,21 @@ console.log('Hello, World!');
|
|||||||
contentTextarea.addEventListener('input', function() {
|
contentTextarea.addEventListener('input', function() {
|
||||||
this.style.height = 'auto';
|
this.style.height = 'auto';
|
||||||
this.style.height = (this.scrollHeight) + 'px';
|
this.style.height = (this.scrollHeight) + 'px';
|
||||||
|
renderPreview();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initial preview state
|
||||||
|
document.addEventListener('DOMContentLoaded', applyPreviewState);
|
||||||
|
|
||||||
|
// Toggle preview
|
||||||
|
if (togglePreviewBtn) {
|
||||||
|
togglePreviewBtn.addEventListener('click', function() {
|
||||||
|
previewEnabled = !previewEnabled;
|
||||||
|
localStorage.setItem(PREVIEW_STATE_KEY, String(previewEnabled));
|
||||||
|
applyPreviewState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
contentTextarea.addEventListener('keydown', function(e) {
|
contentTextarea.addEventListener('keydown', function(e) {
|
||||||
// Ctrl+S or Cmd+S to save
|
// Ctrl+S or Cmd+S to save
|
||||||
@@ -141,7 +230,68 @@ console.log('Hello, World!');
|
|||||||
|
|
||||||
this.value = value.substring(0, start) + '\t' + value.substring(end);
|
this.value = value.substring(0, start) + '\t' + value.substring(end);
|
||||||
this.selectionStart = this.selectionEnd = start + 1;
|
this.selectionStart = this.selectionEnd = start + 1;
|
||||||
|
renderPreview();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Paste-to-upload image
|
||||||
|
contentTextarea.addEventListener('paste', async function(e) {
|
||||||
|
const items = (e.clipboardData || window.clipboardData).items || [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.type && item.type.indexOf('image') === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
const blob = item.getAsFile();
|
||||||
|
const ext = blob.type.split('/')[1] || 'png';
|
||||||
|
const filename = `pasted-${Date.now()}.${ext}`;
|
||||||
|
|
||||||
|
// Compute upload path hint
|
||||||
|
let uploadPath = '';
|
||||||
|
if (imageStorageMode === 3 || imageStorageMode === 4) {
|
||||||
|
// Use folder path with a dummy file to hint the directory
|
||||||
|
if (currentFolderPath) {
|
||||||
|
uploadPath = currentFolderPath + '/_new_.md';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', blob, filename);
|
||||||
|
formData.append('path', uploadPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/upload', { method: 'POST', body: formData });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || !data.success) throw new Error(data.error || 'Upload failed');
|
||||||
|
|
||||||
|
// Build Obsidian link path for current draft
|
||||||
|
let obsidianPath = filename;
|
||||||
|
if (imageStorageMode === 4 && currentFolderPath) {
|
||||||
|
obsidianPath = `${currentFolderPath}/${imageSubfolderName}/${filename}`;
|
||||||
|
} else if (imageStorageMode === 3 && currentFolderPath) {
|
||||||
|
obsidianPath = `${currentFolderPath}/${filename}`;
|
||||||
|
} else if (imageStorageMode === 1) {
|
||||||
|
obsidianPath = filename;
|
||||||
|
} // mode 2 uses filename only
|
||||||
|
|
||||||
|
insertAtCursor(contentTextarea, `![[${obsidianPath}]]`);
|
||||||
|
renderPreview();
|
||||||
|
showNotification('Image pasted and uploaded', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showNotification('Paste upload failed: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function insertAtCursor(textarea, text) {
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const value = textarea.value;
|
||||||
|
textarea.value = value.substring(0, start) + text + value.substring(end);
|
||||||
|
const pos = start + text.length;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(pos, pos);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h1 class="text-3xl font-bold text-white">Edit: {{.title}}</h1>
|
<h1 class="text-3xl font-bold text-white">Edit: {{.title}}</h1>
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<a href="/note/{{.note_path}}" class="btn-secondary">
|
<button id="toggle-preview" type="button" class="btn-secondary">
|
||||||
<i class="fas fa-eye mr-2"></i>Preview
|
<i class="fas fa-eye mr-2"></i>Preview
|
||||||
</a>
|
</button>
|
||||||
<button type="submit" form="edit-form" class="btn-primary">
|
<button type="submit" form="edit-form" class="btn-primary">
|
||||||
<i class="fas fa-save mr-2"></i>Save
|
<i class="fas fa-save mr-2"></i>Save
|
||||||
</button>
|
</button>
|
||||||
@@ -29,13 +29,16 @@
|
|||||||
<!-- Edit Form -->
|
<!-- Edit Form -->
|
||||||
<form id="edit-form" class="space-y-6">
|
<form id="edit-form" class="space-y-6">
|
||||||
<div class="bg-gray-800 rounded-lg p-6">
|
<div class="bg-gray-800 rounded-lg p-6">
|
||||||
<!-- Content Editor -->
|
<!-- Content Editor with Live Preview -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label for="content" class="block text-sm font-medium text-gray-300 mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
Content
|
<label for="content" class="block text-sm font-medium text-gray-300">Content</label>
|
||||||
</label>
|
<div class="text-xs text-gray-400">Live Preview</div>
|
||||||
<textarea id="content" name="content" rows="25"
|
</div>
|
||||||
class="editor-textarea">{{.content}}</textarea>
|
<div id="editor-grid" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<textarea id="content" name="content" rows="25" class="editor-textarea">{{.content}}</textarea>
|
||||||
|
<div id="live-preview" class="prose prose-invert prose-dark bg-slate-800 border border-gray-700 rounded-lg p-4 overflow-y-auto hidden" style="min-height: 20rem;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Editor Toolbar -->
|
<!-- Editor Toolbar -->
|
||||||
@@ -77,6 +80,74 @@
|
|||||||
<script>
|
<script>
|
||||||
const editForm = document.getElementById('edit-form');
|
const editForm = document.getElementById('edit-form');
|
||||||
const contentTextarea = document.getElementById('content');
|
const contentTextarea = document.getElementById('content');
|
||||||
|
const livePreview = document.getElementById('live-preview');
|
||||||
|
const editorGrid = document.getElementById('editor-grid');
|
||||||
|
const togglePreviewBtn = document.getElementById('toggle-preview');
|
||||||
|
|
||||||
|
const imageStorageMode = {{.image_storage_mode}};
|
||||||
|
const imageSubfolderName = "{{.image_subfolder_name}}";
|
||||||
|
const currentFolderPath = "{{.folder_path}}";
|
||||||
|
const currentNotePath = "{{.note_path}}";
|
||||||
|
|
||||||
|
// Preview state (persist per-note)
|
||||||
|
const PREVIEW_STATE_KEY = `previewEnabled:${currentNotePath}`;
|
||||||
|
let previewEnabled = localStorage.getItem(PREVIEW_STATE_KEY) === 'true';
|
||||||
|
|
||||||
|
// Load Marked for client-side markdown rendering
|
||||||
|
(function ensureMarked() {
|
||||||
|
if (window.marked) return;
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = 'https://cdn.jsdelivr.net/npm/marked/marked.min.js';
|
||||||
|
document.head.appendChild(s);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function buildImageURL(filename) {
|
||||||
|
// Map Obsidian embed to server URL
|
||||||
|
if (imageStorageMode === 2) {
|
||||||
|
return `/serve_stored_image/${filename}`;
|
||||||
|
}
|
||||||
|
let path = filename;
|
||||||
|
if (imageStorageMode === 3 && currentFolderPath) {
|
||||||
|
path = `${currentFolderPath}/${filename}`;
|
||||||
|
} else if (imageStorageMode === 4 && currentFolderPath) {
|
||||||
|
path = `${currentFolderPath}/${imageSubfolderName}/${filename}`;
|
||||||
|
}
|
||||||
|
return `/serve_attached_image/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformObsidianEmbeds(md) {
|
||||||
|
// Convert ![[name.png]] or ![[sub/name.png|alt]] to 
|
||||||
|
return (md || '').replace(/!\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (m, file, alt) => {
|
||||||
|
const filename = file.trim();
|
||||||
|
const url = buildImageURL(filename);
|
||||||
|
const altText = (alt || filename).trim();
|
||||||
|
return ``;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreviewState() {
|
||||||
|
if (!livePreview || !editorGrid) return;
|
||||||
|
if (previewEnabled) {
|
||||||
|
livePreview.classList.remove('hidden');
|
||||||
|
editorGrid.classList.add('lg:grid-cols-2');
|
||||||
|
renderPreview();
|
||||||
|
togglePreviewBtn && (togglePreviewBtn.innerHTML = '<i class="fas fa-eye-slash mr-2"></i>Hide Preview');
|
||||||
|
} else {
|
||||||
|
livePreview.classList.add('hidden');
|
||||||
|
editorGrid.classList.remove('lg:grid-cols-2');
|
||||||
|
togglePreviewBtn && (togglePreviewBtn.innerHTML = '<i class="fas fa-eye mr-2"></i>Preview');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview() {
|
||||||
|
if (!previewEnabled) return;
|
||||||
|
if (!livePreview || !window.marked) return;
|
||||||
|
const transformed = transformObsidianEmbeds(contentTextarea.value || '');
|
||||||
|
livePreview.innerHTML = marked.parse(transformed);
|
||||||
|
livePreview.querySelectorAll('pre code').forEach(block => {
|
||||||
|
try { hljs.highlightElement(block); } catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
editForm.addEventListener('submit', function(e) {
|
editForm.addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -113,6 +184,7 @@
|
|||||||
function autoResize() {
|
function autoResize() {
|
||||||
contentTextarea.style.height = 'auto';
|
contentTextarea.style.height = 'auto';
|
||||||
contentTextarea.style.height = (contentTextarea.scrollHeight) + 'px';
|
contentTextarea.style.height = (contentTextarea.scrollHeight) + 'px';
|
||||||
|
renderPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
contentTextarea.addEventListener('input', autoResize);
|
contentTextarea.addEventListener('input', autoResize);
|
||||||
@@ -120,6 +192,18 @@
|
|||||||
// Initial resize
|
// Initial resize
|
||||||
autoResize();
|
autoResize();
|
||||||
|
|
||||||
|
// Initial preview state
|
||||||
|
document.addEventListener('DOMContentLoaded', applyPreviewState);
|
||||||
|
|
||||||
|
// Toggle preview
|
||||||
|
if (togglePreviewBtn) {
|
||||||
|
togglePreviewBtn.addEventListener('click', function() {
|
||||||
|
previewEnabled = !previewEnabled;
|
||||||
|
localStorage.setItem(PREVIEW_STATE_KEY, String(previewEnabled));
|
||||||
|
applyPreviewState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
contentTextarea.addEventListener('keydown', function(e) {
|
contentTextarea.addEventListener('keydown', function(e) {
|
||||||
// Ctrl+S or Cmd+S to save
|
// Ctrl+S or Cmd+S to save
|
||||||
@@ -137,6 +221,7 @@
|
|||||||
|
|
||||||
this.value = value.substring(0, start) + '\t' + value.substring(end);
|
this.value = value.substring(0, start) + '\t' + value.substring(end);
|
||||||
this.selectionStart = this.selectionEnd = start + 1;
|
this.selectionStart = this.selectionEnd = start + 1;
|
||||||
|
renderPreview();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,5 +273,62 @@
|
|||||||
|
|
||||||
autoResize();
|
autoResize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paste-to-upload image
|
||||||
|
contentTextarea.addEventListener('paste', async function(e) {
|
||||||
|
const items = (e.clipboardData || window.clipboardData).items || [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.type && item.type.indexOf('image') === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
const blob = item.getAsFile();
|
||||||
|
const ext = blob.type.split('/')[1] || 'png';
|
||||||
|
const filename = `pasted-${Date.now()}.${ext}`;
|
||||||
|
|
||||||
|
// Compute upload path hint
|
||||||
|
let uploadPath = currentNotePath || '';
|
||||||
|
if (!uploadPath && (imageStorageMode === 3 || imageStorageMode === 4) && currentFolderPath) {
|
||||||
|
uploadPath = currentFolderPath + '/_new_.md';
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', blob, filename);
|
||||||
|
formData.append('path', uploadPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/upload', { method: 'POST', body: formData });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || !data.success) throw new Error(data.error || 'Upload failed');
|
||||||
|
|
||||||
|
// Build Obsidian link path for current note
|
||||||
|
let obsidianPath = filename;
|
||||||
|
if (imageStorageMode === 4 && currentFolderPath) {
|
||||||
|
obsidianPath = `${currentFolderPath}/${imageSubfolderName}/${filename}`;
|
||||||
|
} else if (imageStorageMode === 3 && currentFolderPath) {
|
||||||
|
obsidianPath = `${currentFolderPath}/${filename}`;
|
||||||
|
} else if (imageStorageMode === 1) {
|
||||||
|
obsidianPath = filename;
|
||||||
|
} // mode 2 uses filename only
|
||||||
|
|
||||||
|
insertAtCursor(contentTextarea, `![[${obsidianPath}]]`);
|
||||||
|
renderPreview();
|
||||||
|
showNotification('Image pasted and uploaded', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showNotification('Paste upload failed: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function insertAtCursor(textarea, text) {
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const value = textarea.value;
|
||||||
|
textarea.value = value.substring(0, start) + text + value.substring(end);
|
||||||
|
const pos = start + text.length;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(pos, pos);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user