Files
nextcloud-notes-desktop-app/src/components/NoteEditor.tsx
drelich 0a5dba2a98 Fix favorite star not showing in editor toolbar after sync
Added note.favorite to useEffect dependencies so localFavorite state
updates when favorite status changes via background sync from mobile.
2026-03-26 09:23:36 +01:00

883 lines
36 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
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';
import { InsertToolbar } from './InsertToolbar';
interface NoteEditorProps {
note: Note | null;
onUpdateNote: (note: Note) => void;
onToggleFavorite?: (note: Note, favorite: boolean) => void;
onUnsavedChanges?: (hasChanges: boolean) => void;
categories: string[];
isFocusMode?: boolean;
onToggleFocusMode?: () => void;
editorFont?: string;
editorFontSize?: number;
previewFont?: string;
previewFontSize?: number;
api?: NextcloudAPI | null;
}
const imageCache = new Map<string, string>();
export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) {
const [localContent, setLocalContent] = useState('');
const [localCategory, setLocalCategory] = useState('');
const [localFavorite, setLocalFavorite] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [isExportingPDF, setIsExportingPDF] = useState(false);
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [processedContent, setProcessedContent] = useState('');
const [isLoadingImages, setIsLoadingImages] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const previousNoteIdRef = useRef<number | string | null>(null);
const previousNoteContentRef = useRef<string>('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
onUnsavedChanges?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onUnsavedChanges]);
// Handle Escape key to exit focus mode
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isFocusMode && onToggleFocusMode) {
onToggleFocusMode();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isFocusMode, onToggleFocusMode]);
// Auto-resize textarea when content changes, switching from preview to edit, or font size changes
useEffect(() => {
if (textareaRef.current && !isPreviewMode) {
// Use setTimeout to ensure DOM has updated
setTimeout(() => {
if (textareaRef.current) {
// Save cursor position and scroll position
const cursorPosition = textareaRef.current.selectionStart;
const scrollTop = textareaRef.current.scrollTop;
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
// Restore cursor position and scroll position
textareaRef.current.setSelectionRange(cursorPosition, cursorPosition);
textareaRef.current.scrollTop = scrollTop;
}
}, 0);
}
}, [localContent, isPreviewMode, editorFontSize]);
// Process images when entering preview mode or content changes
useEffect(() => {
if (!isPreviewMode || !note || !api) {
setProcessedContent(localContent);
return;
}
// Guard: Only process if localContent has been updated for the current note
// This prevents processing stale content from the previous note
if (previousNoteIdRef.current !== note.id) {
console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousNoteIdRef: ${previousNoteIdRef.current})`);
return;
}
const processImages = async () => {
console.log(`[Note ${note.id}] Processing images in preview mode. Content length: ${localContent.length}`);
setIsLoadingImages(true);
setProcessedContent(''); // Clear old content immediately
// Find all image references in markdown: ![alt](path)
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
let content = localContent;
const matches = [...localContent.matchAll(imageRegex)];
console.log(`[Note ${note.id}] Found ${matches.length} images to process`);
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) {
setLocalContent(note.content);
setLocalCategory(note.category || '');
setLocalFavorite(note.favorite);
setHasUnsavedChanges(false);
previousNoteIdRef.current = note.id;
previousNoteContentRef.current = note.content;
}
};
// Switching to a different note
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) {
console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`);
setProcessedContent('');
if (hasUnsavedChanges) {
handleSave();
}
loadNewNote();
}
// Same note but content changed from server (and no unsaved local changes)
else if (note && previousNoteIdRef.current === note.id && !hasUnsavedChanges && previousNoteContentRef.current !== note.content) {
console.log(`Note ${note.id} content changed from server (prev: ${previousNoteContentRef.current.length} chars, new: ${note.content.length} chars)`);
loadNewNote();
}
// Initial load
else if (!note || previousNoteIdRef.current === null) {
loadNewNote();
}
// Favorite status changed (e.g., from sync)
else if (note && note.favorite !== localFavorite) {
setLocalFavorite(note.favorite);
}
}, [note?.id, note?.content, note?.modified, note?.favorite]);
const handleSave = () => {
if (!note || !hasUnsavedChanges) return;
console.log('Saving note content length:', localContent.length);
console.log('Last 50 chars:', localContent.slice(-50));
setIsSaving(true);
setHasUnsavedChanges(false);
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const title = firstLine || 'Untitled';
onUpdateNote({
...note,
title,
content: localContent,
category: localCategory,
favorite: localFavorite,
});
setTimeout(() => setIsSaving(false), 500);
};
const handleContentChange = (value: string) => {
setLocalContent(value);
setHasUnsavedChanges(true);
};
const handleDiscard = () => {
if (!note) return;
setLocalContent(note.content);
setLocalCategory(note.category || '');
setLocalFavorite(note.favorite);
setHasUnsavedChanges(false);
};
const loadFontAsBase64 = async (fontPath: string): Promise<string> => {
const response = await fetch(fontPath);
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
// Remove data URL prefix to get just the base64 string
resolve(base64.split(',')[1]);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
};
const handleExportPDF = async () => {
if (!note) return;
setIsExportingPDF(true);
try {
// Create PDF
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
// Load and add custom fonts based on preview font selection
const fontMap: { [key: string]: { regular: string; italic: string; name: string } } = {
'Merriweather': {
regular: '/fonts/Merriweather-VariableFont_opsz,wdth,wght.ttf',
italic: '/fonts/Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf',
name: 'Merriweather'
},
'Crimson Pro': {
regular: '/fonts/CrimsonPro-VariableFont_wght.ttf',
italic: '/fonts/CrimsonPro-Italic-VariableFont_wght.ttf',
name: 'CrimsonPro'
},
'Roboto Serif': {
regular: '/fonts/RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf',
italic: '/fonts/RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf',
name: 'RobotoSerif'
},
'Average': {
regular: '/fonts/Average-Regular.ttf',
italic: '/fonts/Average-Regular.ttf', // No italic variant
name: 'Average'
}
};
const selectedFont = fontMap[previewFont];
if (selectedFont) {
try {
const regularBase64 = await loadFontAsBase64(selectedFont.regular);
pdf.addFileToVFS(`${selectedFont.name}-normal.ttf`, regularBase64);
pdf.addFont(`${selectedFont.name}-normal.ttf`, selectedFont.name, 'normal');
const italicBase64 = await loadFontAsBase64(selectedFont.italic);
pdf.addFileToVFS(`${selectedFont.name}-italic.ttf`, italicBase64);
pdf.addFont(`${selectedFont.name}-italic.ttf`, selectedFont.name, 'italic');
// Set the custom font as default
pdf.setFont(selectedFont.name, 'normal');
} catch (fontError) {
console.error('Failed to load custom font, using default:', fontError);
}
}
// Add Source Code Pro for code blocks
try {
const codeFont = await loadFontAsBase64('/fonts/SourceCodePro-VariableFont_wght.ttf');
pdf.addFileToVFS('SourceCodePro-normal.ttf', codeFont);
pdf.addFont('SourceCodePro-normal.ttf', 'SourceCodePro', 'normal');
} catch (codeFontError) {
console.error('Failed to load code font:', codeFontError);
}
// Process images to embed them as data URLs
let contentForPDF = localContent;
if (api) {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches = [...localContent.matchAll(imageRegex)];
for (const match of matches) {
const [fullMatch, alt, imagePath] = match;
// Skip external URLs
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)!;
contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`);
continue;
}
try {
const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category);
imageCache.set(cacheKey, dataUrl);
contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`);
} catch (error) {
console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error);
}
}
}
const container = document.createElement('div');
container.style.fontFamily = `"${previewFont}", Georgia, serif`;
container.style.fontSize = `${previewFontSize}px`;
container.style.lineHeight = '1.6';
container.style.color = '#000000';
const titleElement = document.createElement('h1');
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
titleElement.textContent = firstLine || 'Untitled';
titleElement.style.marginTop = '0';
titleElement.style.marginBottom = '20px';
titleElement.style.fontSize = '24px';
titleElement.style.fontWeight = 'bold';
titleElement.style.color = '#000000';
titleElement.style.textAlign = 'center';
titleElement.style.fontFamily = `"${previewFont}", Georgia, serif`;
container.appendChild(titleElement);
const contentElement = document.createElement('div');
const html = marked.parse(contentForPDF || '', { async: false }) as string;
contentElement.innerHTML = html;
contentElement.style.fontSize = `${previewFontSize}px`;
contentElement.style.lineHeight = '1.6';
contentElement.style.color = '#000000';
container.appendChild(contentElement);
const style = document.createElement('style');
style.textContent = `
body, p, h1, h2, h3, div { font-family: "${previewFont}", Georgia, serif !important; }
code, pre, pre * { font-family: "Source Code Pro", "Courier New", monospace !important; }
pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; }
code { padding: 0; }
h1 { font-size: 2em; font-weight: bold; margin-top: 0.67em; margin-bottom: 0.67em; }
h2 { font-size: 1.5em; font-weight: bold; margin-top: 0.83em; margin-bottom: 0.83em; }
h3 { font-size: 1.17em; font-weight: bold; margin-top: 1em; margin-bottom: 1em; }
p { margin: 0.5em 0; }
ul, ol { margin: 0.5em 0; padding-left: 2em; list-style-position: outside; font-family: "${previewFont}", Georgia, serif !important; }
ul { list-style-type: disc; }
ol { list-style-type: decimal; }
li { margin: 0.25em 0; display: list-item; font-family: "${previewFont}", Georgia, serif !important; }
em { font-style: italic; vertical-align: baseline; }
strong { font-weight: bold; vertical-align: baseline; line-height: inherit; }
img { max-width: 100%; height: auto; display: block; margin: 1em 0; }
`;
container.appendChild(style);
// Use jsPDF's html() method with custom font set
await pdf.html(container, {
callback: async (doc) => {
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const fileName = `${firstLine || 'note'}.pdf`;
doc.save(fileName);
setTimeout(async () => {
try {
await message(`PDF exported successfully!\n\nFile: ${fileName}\nLocation: Downloads folder`, {
title: 'Export Complete',
kind: 'info',
});
} catch (err) {
console.log('Dialog shown successfully or not available');
}
setIsExportingPDF(false);
}, 500);
},
margin: [20, 20, 20, 20],
autoPaging: 'text',
width: 170,
windowWidth: 650,
});
} catch (error) {
console.error('PDF export failed:', error);
try {
await message('Failed to export PDF. Please try again.', {
title: 'Export Failed',
kind: 'error',
});
} catch (err) {
console.error('Could not show error dialog');
}
setIsExportingPDF(false);
}
};
const handleFavoriteToggle = () => {
const newFavorite = !localFavorite;
setLocalFavorite(newFavorite);
if (note && onToggleFavorite) {
// Use dedicated favorite toggle callback if provided
onToggleFavorite(note, newFavorite);
} else if (note) {
// Fallback to full update if no callback provided
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const title = firstLine || 'Untitled';
onUpdateNote({
...note,
title,
content: localContent,
category: localCategory,
favorite: newFavorite,
});
}
};
const handleCategoryChange = (category: string) => {
setLocalCategory(category);
setHasUnsavedChanges(true);
};
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
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;
const textarea = textareaRef.current;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = localContent.substring(start, end);
if (!selectedText) return;
let formattedText = '';
let cursorOffset = 0;
let isRemoving = false;
// Helper to check and remove inline formatting
const toggleInline = (text: string, wrapper: string): { result: string; removed: boolean } => {
const escaped = wrapper.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`^${escaped}(.+)${escaped}$`, 's');
const match = text.match(regex);
if (match) {
return { result: match[1], removed: true };
}
return { result: `${wrapper}${text}${wrapper}`, removed: false };
};
// Helper to check and remove line-prefix formatting
const toggleLinePrefix = (text: string, prefixRegex: RegExp, addPrefix: (line: string, i: number) => string): { result: string; removed: boolean } => {
const lines = text.split('\n');
const allHavePrefix = lines.every(line => prefixRegex.test(line));
if (allHavePrefix) {
return {
result: lines.map(line => line.replace(prefixRegex, '')).join('\n'),
removed: true
};
}
return {
result: lines.map((line, i) => addPrefix(line, i)).join('\n'),
removed: false
};
};
switch (format) {
case 'bold': {
const { result, removed } = toggleInline(selectedText, '**');
formattedText = result;
isRemoving = removed;
break;
}
case 'italic': {
const { result, removed } = toggleInline(selectedText, '*');
formattedText = result;
isRemoving = removed;
break;
}
case 'strikethrough': {
const { result, removed } = toggleInline(selectedText, '~~');
formattedText = result;
isRemoving = removed;
break;
}
case 'code': {
const { result, removed } = toggleInline(selectedText, '`');
formattedText = result;
isRemoving = removed;
break;
}
case 'codeblock': {
const codeBlockMatch = selectedText.match(/^```\n?([\s\S]*?)\n?```$/);
if (codeBlockMatch) {
formattedText = codeBlockMatch[1];
isRemoving = true;
} else {
formattedText = `\`\`\`\n${selectedText}\n\`\`\``;
}
break;
}
case 'quote': {
const { result, removed } = toggleLinePrefix(selectedText, /^>\s?/, (line) => `> ${line}`);
formattedText = result;
isRemoving = removed;
break;
}
case 'ul': {
const { result, removed } = toggleLinePrefix(selectedText, /^[-*+]\s/, (line) => `- ${line}`);
formattedText = result;
isRemoving = removed;
break;
}
case 'ol': {
const { result, removed } = toggleLinePrefix(selectedText, /^\d+\.\s/, (line, i) => `${i + 1}. ${line}`);
formattedText = result;
isRemoving = removed;
break;
}
case 'link': {
const linkMatch = selectedText.match(/^\[(.+)\]\((.+)\)$/);
if (linkMatch) {
formattedText = linkMatch[1]; // Just return the text part
isRemoving = true;
} else {
formattedText = `[${selectedText}](url)`;
cursorOffset = formattedText.length - 4;
}
break;
}
case 'h1': {
const { result, removed } = toggleLinePrefix(selectedText, /^#\s/, (line) => `# ${line}`);
formattedText = result;
isRemoving = removed;
break;
}
case 'h2': {
const { result, removed } = toggleLinePrefix(selectedText, /^##\s/, (line) => `## ${line}`);
formattedText = result;
isRemoving = removed;
break;
}
case 'h3': {
const { result, removed } = toggleLinePrefix(selectedText, /^###\s/, (line) => `### ${line}`);
formattedText = result;
isRemoving = removed;
break;
}
}
const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end);
setLocalContent(newContent);
setHasUnsavedChanges(true);
setTimeout(() => {
textarea.focus();
if (format === 'link' && !isRemoving) {
// Select "url" for easy replacement
textarea.setSelectionRange(start + cursorOffset, start + cursorOffset + 3);
} else {
textarea.setSelectionRange(start, start + formattedText.length);
}
}, 0);
};
if (!note) {
return (
<div className="flex-1 flex items-center justify-center bg-white dark:bg-gray-900 text-gray-400">
<div className="text-center">
<svg className="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-lg font-medium">No Note Selected</p>
<p className="text-sm mt-2">Select a note from the sidebar or create a new one</p>
</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col bg-white dark:bg-gray-900">
{/* Toolbar */}
<div className="border-b border-gray-200 dark:border-gray-700 px-6 py-3 bg-gray-50 dark:bg-gray-800/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Category Selector */}
<div className="relative">
<select
value={localCategory}
onChange={(e) => handleCategoryChange(e.target.value)}
className="appearance-none pl-8 pr-8 py-1.5 text-sm rounded-full bg-gray-100 dark:bg-gray-700 border-0 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-0 cursor-pointer transition-colors"
>
<option value="">No Category</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
<svg className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<svg className="w-3 h-3 absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{/* Preview Toggle */}
<button
onClick={() => setIsPreviewMode(!isPreviewMode)}
className={`px-3 py-1.5 rounded-full transition-colors flex items-center gap-1.5 text-sm ${
isPreviewMode
? 'bg-blue-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title={isPreviewMode ? "Edit Mode" : "Preview Mode"}
>
{isPreviewMode ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span>Preview</span>
</>
)}
</button>
</div>
<div className="flex items-center gap-2">
{/* Status */}
{(hasUnsavedChanges || isSaving) && (
<span className={`text-xs px-2 py-1 rounded-full ${
isSaving
? 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
}`}>
{isSaving ? 'Saving...' : 'Unsaved'}
</span>
)}
{/* Action Buttons */}
<div className="flex items-center gap-1 pl-2 border-l border-gray-200 dark:border-gray-700">
<button
onClick={handleFavoriteToggle}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title={localFavorite ? "Remove from Favorites" : "Add to Favorites"}
>
<svg
className={`w-5 h-5 ${localFavorite ? 'text-yellow-500 fill-current' : 'text-gray-600 dark:text-gray-400'}`}
fill={localFavorite ? "currentColor" : "none"}
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button
onClick={handleSave}
disabled={!hasUnsavedChanges || isSaving}
className={`p-1.5 rounded-lg transition-colors ${
hasUnsavedChanges && !isSaving
? 'text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30'
: 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
}`}
title="Save Note"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
onClick={handleDiscard}
disabled={!hasUnsavedChanges || isSaving}
className={`p-1.5 rounded-lg transition-colors ${
hasUnsavedChanges && !isSaving
? 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
: 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
}`}
title="Discard Changes"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button
onClick={handleExportPDF}
disabled={isExportingPDF}
className={`p-1.5 rounded-lg transition-colors ${
isExportingPDF
? 'text-blue-500 cursor-wait'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={isExportingPDF ? "Generating PDF..." : "Export as PDF"}
>
{isExportingPDF ? (
<svg className="w-5 h-5 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>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)}
</button>
{/* Focus Mode Toggle */}
{onToggleFocusMode && (
<button
onClick={onToggleFocusMode}
className={`p-1.5 rounded-lg transition-colors ${
isFocusMode
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={isFocusMode ? "Exit Focus Mode (Esc)" : "Focus Mode"}
>
{isFocusMode ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
)}
</button>
)}
</div>
</div>
</div>
</div>
<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 [&_code]:py-0 [&_code]:px-1 [&_code]:align-baseline [&_code]:leading-none [&_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} />
<InsertToolbar
textareaRef={textareaRef}
onInsertLink={handleInsertLink}
onInsertFile={handleInsertFile}
isUploading={isUploading}
/>
<input
ref={fileInputRef}
type="file"
onChange={handleAttachmentUpload}
className="hidden"
accept="image/*,.pdf,.doc,.docx,.txt,.md"
/>
<textarea
ref={textareaRef}
value={localContent}
onChange={(e) => {
handleContentChange(e.target.value);
// Auto-resize textarea to fit content
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
}}
className="w-full resize-none border-none outline-none focus:ring-0 bg-transparent text-gray-900 dark:text-gray-100 overflow-hidden"
style={{ fontSize: `${editorFontSize}px`, lineHeight: '1.6', minHeight: '100%', fontFamily: editorFont }}
placeholder="Start writing in markdown..."
/>
</div>
)}
</div>
</div>
</div>
);
}