Add image/attachment support in preview mode

- Fetch attachments via WebDAV using Tauri HTTP plugin (bypasses CORS)
- Parse markdown for image references and convert to base64 data URLs
- In-memory cache to avoid re-fetching images
- Loading indicator while images load
- Register tauri-plugin-http in Rust builder
- Add HTTP permissions in capabilities
This commit is contained in:
drelich
2026-03-18 14:25:03 +01:00
parent 7611f8e82e
commit 7fd765ceb6
8 changed files with 142 additions and 9 deletions

View File

@@ -281,6 +281,7 @@ function App() {
editorFontSize={editorFontSize}
previewFont={previewFont}
previewFontSize={previewFontSize}
api={api}
/>
</div>
);

View File

@@ -1,13 +1,18 @@
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { Note, APIConfig } from '../types';
export class NextcloudAPI {
private baseURL: string;
private serverURL: string;
private authHeader: string;
private username: string;
constructor(config: APIConfig) {
const url = config.serverURL.replace(/\/$/, '');
this.serverURL = url;
this.baseURL = `${url}/index.php/apps/notes/api/v1`;
this.authHeader = 'Basic ' + btoa(`${config.username}:${config.password}`);
this.username = config.username;
}
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
@@ -55,4 +60,45 @@ export class NextcloudAPI {
async deleteNote(id: number): Promise<void> {
await this.request<void>(`/notes/${id}`, { method: 'DELETE' });
}
async fetchAttachment(_noteId: number, path: string, noteCategory?: string): Promise<string> {
// Build WebDAV path: /remote.php/dav/files/{username}/Notes/{category}/.attachments.{noteId}/{filename}
// The path from markdown is like: .attachments.38479/Screenshot.png
// We need to construct the full WebDAV URL
let webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
// Add category subfolder if present
if (noteCategory) {
webdavPath += `/${noteCategory}`;
}
// Add the attachment path (already includes .attachments.{id}/filename)
webdavPath += `/${path}`;
const url = `${this.serverURL}${webdavPath}`;
console.log('Fetching attachment via WebDAV:', url);
const response = await tauriFetch(url, {
headers: {
'Authorization': this.authHeader,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch attachment: ${response.status}`);
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
getServerURL(): string {
return this.serverURL;
}
}

View File

@@ -3,6 +3,7 @@ import { marked } from 'marked';
import jsPDF from 'jspdf';
import { message } from '@tauri-apps/plugin-dialog';
import { Note } from '../types';
import { NextcloudAPI } from '../api/nextcloud';
import { FloatingToolbar } from './FloatingToolbar';
interface NoteEditorProps {
@@ -16,10 +17,13 @@ interface NoteEditorProps {
editorFontSize?: number;
previewFont?: string;
previewFontSize?: number;
api?: NextcloudAPI | null;
}
const imageCache = new Map<string, string>();
export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16 }: NoteEditorProps) {
export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) {
const [localTitle, setLocalTitle] = useState('');
const [localContent, setLocalContent] = useState('');
const [localCategory, setLocalCategory] = useState('');
@@ -29,6 +33,8 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
const [titleManuallyEdited, setTitleManuallyEdited] = useState(false);
const [isExportingPDF, setIsExportingPDF] = useState(false);
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [processedContent, setProcessedContent] = useState('');
const [isLoadingImages, setIsLoadingImages] = useState(false);
const previousNoteIdRef = useRef<number | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -61,6 +67,54 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
}
}, [localContent, isPreviewMode, editorFontSize]);
// Process images when entering preview mode or content changes
useEffect(() => {
if (!isPreviewMode || !note || !api) {
setProcessedContent(localContent);
return;
}
const processImages = async () => {
setIsLoadingImages(true);
// Find all image references in markdown: ![alt](path)
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
let content = localContent;
const matches = [...localContent.matchAll(imageRegex)];
for (const match of matches) {
const [fullMatch, alt, imagePath] = match;
// Skip external URLs (http/https)
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
continue;
}
// Check cache first
const cacheKey = `${note.id}:${imagePath}`;
if (imageCache.has(cacheKey)) {
const dataUrl = imageCache.get(cacheKey)!;
content = content.replace(fullMatch, `![${alt}](${dataUrl})`);
continue;
}
try {
const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category);
imageCache.set(cacheKey, dataUrl);
content = content.replace(fullMatch, `![${alt}](${dataUrl})`);
} catch (error) {
console.error(`Failed to fetch attachment: ${imagePath}`, error);
// Keep original path, image will show as broken
}
}
setProcessedContent(content);
setIsLoadingImages(false);
};
processImages();
}, [isPreviewMode, localContent, note?.id, api]);
useEffect(() => {
const loadNewNote = () => {
if (note) {
@@ -578,13 +632,24 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
<div className="flex-1 overflow-y-auto">
<div className={`min-h-full ${isFocusMode ? 'max-w-3xl mx-auto w-full' : ''}`}>
{isPreviewMode ? (
<div
className={`prose prose-slate dark:prose-invert p-8 ${isFocusMode ? '' : 'max-w-none'} [&_code]:font-mono [&_pre]:font-mono`}
style={{ fontSize: `${previewFontSize}px`, fontFamily: previewFont }}
dangerouslySetInnerHTML={{
__html: marked.parse(localContent || '', { async: false }) as string
}}
/>
<div className="relative">
{isLoadingImages && (
<div className="absolute top-4 right-4 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 px-3 py-1.5 rounded-full shadow-sm">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading images...
</div>
)}
<div
className={`prose prose-slate dark:prose-invert p-8 ${isFocusMode ? '' : 'max-w-none'} [&_code]:font-mono [&_pre]:font-mono [&_img]:max-w-full [&_img]:rounded-lg [&_img]:shadow-md`}
style={{ fontSize: `${previewFontSize}px`, fontFamily: previewFont }}
dangerouslySetInnerHTML={{
__html: marked.parse(processedContent || '', { async: false }) as string
}}
/>
</div>
) : (
<div className="min-h-full p-8">
<FloatingToolbar onFormat={handleFormat} textareaRef={textareaRef} />