From ba4600773aaf6c5e51adbb897d7dc5ae80b9df92 Mon Sep 17 00:00:00 2001 From: drelich Date: Wed, 18 Mar 2026 14:42:36 +0100 Subject: [PATCH] Add attachment upload and InsertToolbar for quick link/file insertion - Add uploadAttachment method to NextcloudAPI (WebDAV PUT to .attachments directory) - Add InsertToolbar component that appears on cursor placement in editor - InsertToolbar provides quick access to insert links (with modal) and files - Add Attach button to main toolbar as alternative upload method - Insert markdown references at cursor position after upload --- src/api/nextcloud.ts | 51 +++++++ src/components/InsertToolbar.tsx | 244 +++++++++++++++++++++++++++++++ src/components/NoteEditor.tsx | 119 +++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 src/components/InsertToolbar.tsx diff --git a/src/api/nextcloud.ts b/src/api/nextcloud.ts index e47996f..d60cf1a 100644 --- a/src/api/nextcloud.ts +++ b/src/api/nextcloud.ts @@ -101,4 +101,55 @@ export class NextcloudAPI { getServerURL(): string { return this.serverURL; } + + async uploadAttachment(noteId: number, file: File, noteCategory?: string): Promise { + // Create .attachments.{noteId} directory path and upload file via WebDAV PUT + // Returns the relative path to insert into markdown + + let webdavPath = `/remote.php/dav/files/${this.username}/Notes`; + + if (noteCategory) { + webdavPath += `/${noteCategory}`; + } + + const attachmentDir = `.attachments.${noteId}`; + const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename + const fullPath = `${webdavPath}/${attachmentDir}/${fileName}`; + + const url = `${this.serverURL}${fullPath}`; + console.log('Uploading attachment via WebDAV:', url); + + // First, try to create the attachments directory (MKCOL) + // This may fail if it already exists, which is fine + try { + await tauriFetch(`${this.serverURL}${webdavPath}/${attachmentDir}`, { + method: 'MKCOL', + headers: { + 'Authorization': this.authHeader, + }, + }); + } catch (e) { + // Directory might already exist, continue + } + + // Read file as ArrayBuffer + const arrayBuffer = await file.arrayBuffer(); + + // Upload the file via PUT + const response = await tauriFetch(url, { + method: 'PUT', + headers: { + 'Authorization': this.authHeader, + 'Content-Type': file.type || 'application/octet-stream', + }, + body: arrayBuffer, + }); + + if (!response.ok && response.status !== 201 && response.status !== 204) { + throw new Error(`Failed to upload attachment: ${response.status}`); + } + + // Return the relative path for markdown + return `${attachmentDir}/${fileName}`; + } } diff --git a/src/components/InsertToolbar.tsx b/src/components/InsertToolbar.tsx new file mode 100644 index 0000000..7a29145 --- /dev/null +++ b/src/components/InsertToolbar.tsx @@ -0,0 +1,244 @@ +import { useEffect, useState, useRef, RefObject } from 'react'; + +interface InsertToolbarProps { + textareaRef: RefObject; + onInsertLink: (text: string, url: string) => void; + onInsertFile: () => void; + isUploading?: boolean; +} + +interface LinkModalState { + isOpen: boolean; + text: string; + url: string; +} + +export function InsertToolbar({ textareaRef, onInsertLink, onInsertFile, isUploading }: InsertToolbarProps) { + const [position, setPosition] = useState<{ top: number; left: number } | null>(null); + const [isVisible, setIsVisible] = useState(false); + const [linkModal, setLinkModal] = useState({ isOpen: false, text: '', url: '' }); + const toolbarRef = useRef(null); + const modalRef = useRef(null); + const urlInputRef = useRef(null); + + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + const updatePosition = () => { + const textarea = textareaRef.current; + if (!textarea || linkModal.isOpen) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + + // Only show when cursor is placed (no selection) + if (start !== end) { + setIsVisible(false); + return; + } + + const textareaRect = textarea.getBoundingClientRect(); + const computedStyle = window.getComputedStyle(textarea); + const lineHeight = parseFloat(computedStyle.lineHeight) || 24; + const paddingTop = parseFloat(computedStyle.paddingTop) || 0; + const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0; + const fontSize = parseFloat(computedStyle.fontSize) || 16; + + const textBeforeCursor = textarea.value.substring(0, start); + const lines = textBeforeCursor.split('\n'); + const currentLineIndex = lines.length - 1; + const currentLineText = lines[currentLineIndex]; + + const charWidth = fontSize * 0.6; + const scrollTop = textarea.scrollTop; + + // Position to the right of cursor + const top = textareaRect.top + paddingTop + (currentLineIndex * lineHeight) - scrollTop + lineHeight / 2; + const left = textareaRect.left + paddingLeft + (currentLineText.length * charWidth) + 20; + + // Keep toolbar within viewport + const toolbarWidth = 100; + const adjustedLeft = Math.min(left, window.innerWidth - toolbarWidth - 20); + let adjustedTop = top - 16; // Center vertically with cursor line + + if (adjustedTop < 10) { + adjustedTop = 10; + } + + setPosition({ top: adjustedTop, left: adjustedLeft }); + setIsVisible(true); + }; + + const handleClick = () => { + setTimeout(updatePosition, 10); + }; + + const handleKeyUp = (e: KeyboardEvent) => { + // Update on arrow keys or other navigation + if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) { + updatePosition(); + } + }; + + const handleInput = () => { + // Hide briefly during typing, show after a pause + setIsVisible(false); + }; + + const handleBlur = () => { + // Don't hide if clicking on toolbar or modal + setTimeout(() => { + const activeElement = document.activeElement; + if ( + activeElement !== textarea && + !toolbarRef.current?.contains(activeElement) && + !modalRef.current?.contains(activeElement) + ) { + setIsVisible(false); + } + }, 150); + }; + + textarea.addEventListener('click', handleClick); + textarea.addEventListener('keyup', handleKeyUp); + textarea.addEventListener('input', handleInput); + textarea.addEventListener('blur', handleBlur); + + return () => { + textarea.removeEventListener('click', handleClick); + textarea.removeEventListener('keyup', handleKeyUp); + textarea.removeEventListener('input', handleInput); + textarea.removeEventListener('blur', handleBlur); + }; + }, [textareaRef, linkModal.isOpen]); + + const handleLinkClick = () => { + setLinkModal({ isOpen: true, text: '', url: '' }); + setTimeout(() => urlInputRef.current?.focus(), 50); + }; + + const handleLinkSubmit = () => { + if (linkModal.url) { + onInsertLink(linkModal.text || linkModal.url, linkModal.url); + setLinkModal({ isOpen: false, text: '', url: '' }); + setIsVisible(false); + textareaRef.current?.focus(); + } + }; + + const handleLinkCancel = () => { + setLinkModal({ isOpen: false, text: '', url: '' }); + textareaRef.current?.focus(); + }; + + const handleFileClick = () => { + onInsertFile(); + setIsVisible(false); + }; + + if (!isVisible || !position) return null; + + // Link Modal + if (linkModal.isOpen) { + return ( +
+
Insert Link
+ +
+
+ + setLinkModal({ ...linkModal, url: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') handleLinkSubmit(); + if (e.key === 'Escape') handleLinkCancel(); + }} + placeholder="https://example.com" + className="w-full px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ +
+ + setLinkModal({ ...linkModal, text: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') handleLinkSubmit(); + if (e.key === 'Escape') handleLinkCancel(); + }} + placeholder="Link text" + className="w-full px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ +
+ + +
+
+ ); + } + + // Insert Toolbar + return ( +
+ + + +
+ ); +} diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index 60eeb7e..eae5e3c 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -5,6 +5,7 @@ import { message } from '@tauri-apps/plugin-dialog'; import { Note } from '../types'; import { NextcloudAPI } from '../api/nextcloud'; import { FloatingToolbar } from './FloatingToolbar'; +import { InsertToolbar } from './InsertToolbar'; interface NoteEditorProps { note: Note | null; @@ -35,8 +36,10 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i const [isPreviewMode, setIsPreviewMode] = useState(false); const [processedContent, setProcessedContent] = useState(''); const [isLoadingImages, setIsLoadingImages] = useState(false); + const [isUploading, setIsUploading] = useState(false); const previousNoteIdRef = useRef(null); const textareaRef = useRef(null); + const fileInputRef = useRef(null); useEffect(() => { onUnsavedChanges?.(hasUnsavedChanges); @@ -295,6 +298,80 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i setHasUnsavedChanges(true); }; + const handleAttachmentUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file || !note || !api) return; + + setIsUploading(true); + try { + const relativePath = await api.uploadAttachment(note.id, file, note.category); + + // Determine if it's an image or other file + const isImage = file.type.startsWith('image/'); + const markdownLink = isImage + ? `![${file.name}](${relativePath})` + : `[${file.name}](${relativePath})`; + + // Insert at cursor position or end of content + const textarea = textareaRef.current; + if (textarea) { + const cursorPos = textarea.selectionStart; + const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos); + setLocalContent(newContent); + setHasUnsavedChanges(true); + + // Move cursor after inserted text + setTimeout(() => { + textarea.focus(); + const newPos = cursorPos + markdownLink.length; + textarea.setSelectionRange(newPos, newPos); + }, 0); + } else { + // Append to end + setLocalContent(localContent + '\n' + markdownLink); + setHasUnsavedChanges(true); + } + + await message(`Attachment uploaded successfully!`, { + title: 'Upload Complete', + kind: 'info', + }); + } catch (error) { + console.error('Upload failed:', error); + await message(`Failed to upload attachment: ${error}`, { + title: 'Upload Failed', + kind: 'error', + }); + } finally { + setIsUploading(false); + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + const handleInsertLink = (text: string, url: string) => { + const textarea = textareaRef.current; + if (!textarea) return; + + const cursorPos = textarea.selectionStart; + const markdownLink = `[${text}](${url})`; + const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos); + setLocalContent(newContent); + setHasUnsavedChanges(true); + + setTimeout(() => { + textarea.focus(); + const newPos = cursorPos + markdownLink.length; + textarea.setSelectionRange(newPos, newPos); + }, 0); + }; + + const handleInsertFile = () => { + fileInputRef.current?.click(); + }; + const handleFormat = (format: 'bold' | 'italic' | 'strikethrough' | 'code' | 'codeblock' | 'quote' | 'ul' | 'ol' | 'link' | 'h1' | 'h2' | 'h3') => { if (!textareaRef.current) return; @@ -507,6 +584,42 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i + {/* Attachment Upload */} + + + {/* Preview Toggle */}