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

10
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tiptap/extension-strike": "^2.27.2", "@tiptap/extension-strike": "^2.27.2",
"@tiptap/extension-underline": "^2.27.2", "@tiptap/extension-underline": "^2.27.2",
@@ -1499,6 +1500,15 @@
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
} }
}, },
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz",
"integrity": "sha512-+F2lEH/c9b0zSsOXKq+5hZNcd9F4IIKCK1T17RqMwpCmVnx2aoqY8yIBccCd25HTYUb3j6NPVbRax/m00hKG8A==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-opener": { "node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3", "version": "2.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",

View File

@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tiptap/extension-strike": "^2.27.2", "@tiptap/extension-strike": "^2.27.2",
"@tiptap/extension-underline": "^2.27.2", "@tiptap/extension-underline": "^2.27.2",

View File

@@ -23,4 +23,5 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri-plugin-dialog = "2.6.0" tauri-plugin-dialog = "2.6.0"
tauri-plugin-http = "2.5.7"

View File

@@ -5,6 +5,14 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default" "opener:default",
"http:default",
{
"identifier": "http:default",
"allow": [
{ "url": "https://*" },
{ "url": "http://*" }
]
}
] ]
} }

View File

@@ -9,6 +9,7 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.invoke_handler(tauri::generate_handler![greet]) .invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

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

View File

@@ -1,13 +1,18 @@
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { Note, APIConfig } from '../types'; import { Note, APIConfig } from '../types';
export class NextcloudAPI { export class NextcloudAPI {
private baseURL: string; private baseURL: string;
private serverURL: string;
private authHeader: string; private authHeader: string;
private username: string;
constructor(config: APIConfig) { constructor(config: APIConfig) {
const url = config.serverURL.replace(/\/$/, ''); const url = config.serverURL.replace(/\/$/, '');
this.serverURL = url;
this.baseURL = `${url}/index.php/apps/notes/api/v1`; this.baseURL = `${url}/index.php/apps/notes/api/v1`;
this.authHeader = 'Basic ' + btoa(`${config.username}:${config.password}`); this.authHeader = 'Basic ' + btoa(`${config.username}:${config.password}`);
this.username = config.username;
} }
private async request<T>(path: string, options: RequestInit = {}): Promise<T> { private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
@@ -55,4 +60,45 @@ export class NextcloudAPI {
async deleteNote(id: number): Promise<void> { async deleteNote(id: number): Promise<void> {
await this.request<void>(`/notes/${id}`, { method: 'DELETE' }); 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 jsPDF from 'jspdf';
import { message } from '@tauri-apps/plugin-dialog'; import { message } from '@tauri-apps/plugin-dialog';
import { Note } from '../types'; import { Note } from '../types';
import { NextcloudAPI } from '../api/nextcloud';
import { FloatingToolbar } from './FloatingToolbar'; import { FloatingToolbar } from './FloatingToolbar';
interface NoteEditorProps { interface NoteEditorProps {
@@ -16,10 +17,13 @@ interface NoteEditorProps {
editorFontSize?: number; editorFontSize?: number;
previewFont?: string; previewFont?: string;
previewFontSize?: number; 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 [localTitle, setLocalTitle] = useState('');
const [localContent, setLocalContent] = useState(''); const [localContent, setLocalContent] = useState('');
const [localCategory, setLocalCategory] = useState(''); const [localCategory, setLocalCategory] = useState('');
@@ -29,6 +33,8 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
const [titleManuallyEdited, setTitleManuallyEdited] = useState(false); const [titleManuallyEdited, setTitleManuallyEdited] = useState(false);
const [isExportingPDF, setIsExportingPDF] = useState(false); const [isExportingPDF, setIsExportingPDF] = useState(false);
const [isPreviewMode, setIsPreviewMode] = useState(false); const [isPreviewMode, setIsPreviewMode] = useState(false);
const [processedContent, setProcessedContent] = useState('');
const [isLoadingImages, setIsLoadingImages] = useState(false);
const previousNoteIdRef = useRef<number | null>(null); const previousNoteIdRef = useRef<number | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -61,6 +67,54 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
} }
}, [localContent, isPreviewMode, editorFontSize]); }, [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(() => { useEffect(() => {
const loadNewNote = () => { const loadNewNote = () => {
if (note) { if (note) {
@@ -578,13 +632,24 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className={`min-h-full ${isFocusMode ? 'max-w-3xl mx-auto w-full' : ''}`}> <div className={`min-h-full ${isFocusMode ? 'max-w-3xl mx-auto w-full' : ''}`}>
{isPreviewMode ? ( {isPreviewMode ? (
<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 <div
className={`prose prose-slate dark:prose-invert p-8 ${isFocusMode ? '' : 'max-w-none'} [&_code]:font-mono [&_pre]:font-mono`} 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 }} style={{ fontSize: `${previewFontSize}px`, fontFamily: previewFont }}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: marked.parse(localContent || '', { async: false }) as string __html: marked.parse(processedContent || '', { async: false }) as string
}} }}
/> />
</div>
) : ( ) : (
<div className="min-h-full p-8"> <div className="min-h-full p-8">
<FloatingToolbar onFormat={handleFormat} textareaRef={textareaRef} /> <FloatingToolbar onFormat={handleFormat} textareaRef={textareaRef} />