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:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.7",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tiptap/extension-strike": "^2.27.2",
|
||||
"@tiptap/extension-underline": "^2.27.2",
|
||||
@@ -1499,6 +1500,15 @@
|
||||
"@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": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.7",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tiptap/extension-strike": "^2.27.2",
|
||||
"@tiptap/extension-underline": "^2.27.2",
|
||||
|
||||
@@ -23,4 +23,5 @@ tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-dialog = "2.6.0"
|
||||
tauri-plugin-http = "2.5.7"
|
||||
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"http:default",
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "https://*" },
|
||||
{ "url": "http://*" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -281,6 +281,7 @@ function App() {
|
||||
editorFontSize={editorFontSize}
|
||||
previewFont={previewFont}
|
||||
previewFontSize={previewFontSize}
|
||||
api={api}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 
|
||||
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, ``);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category);
|
||||
imageCache.set(cacheKey, dataUrl);
|
||||
content = content.replace(fullMatch, ``);
|
||||
} 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="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`}
|
||||
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(localContent || '', { async: false }) as string
|
||||
__html: marked.parse(processedContent || '', { async: false }) as string
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-full p-8">
|
||||
<FloatingToolbar onFormat={handleFormat} textareaRef={textareaRef} />
|
||||
|
||||
Reference in New Issue
Block a user