From 28914207f6efa36facd87dad9035c3b0755006fc Mon Sep 17 00:00:00 2001 From: drelich Date: Tue, 17 Mar 2026 20:13:44 +0100 Subject: [PATCH] feat: Major UI improvements - categories sidebar, floating toolbar, focus mode Categories sidebar: - Collapsible first column with category management - Create new categories directly from sidebar - Thin tab when collapsed - Moved user info, logout, and theme selector here Note editor redesign: - Clean toolbar with pill-style buttons - Category dropdown with folder icon - Preview toggle in toolbar - Streamlined action buttons Floating formatting toolbar: - Appears on text selection - Bold, italic, strikethrough, code, code block, quote, lists, link, headings - Active state highlighting for applied formats - Toggle behavior removes formatting if already applied Focus mode: - Hides sidebars for distraction-free writing - Content centered with max-width - Escape key to exit - Scrolling works from anywhere in viewport --- src/App.tsx | 66 ++- src/components/CategoriesSidebar.tsx | 224 +++++++++ src/components/CategorySelector.tsx | 116 +++++ src/components/FloatingToolbar.tsx | 222 +++++++++ src/components/NoteEditor.tsx | 681 ++++++++++++++++----------- src/components/NotesList.tsx | 75 --- 6 files changed, 1003 insertions(+), 381 deletions(-) create mode 100644 src/components/CategoriesSidebar.tsx create mode 100644 src/components/CategorySelector.tsx create mode 100644 src/components/FloatingToolbar.tsx diff --git a/src/App.tsx b/src/App.tsx index 31b6ea4..5e1d040 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { LoginView } from './components/LoginView'; import { NotesList } from './components/NotesList'; import { NoteEditor } from './components/NoteEditor'; +import { CategoriesSidebar } from './components/CategoriesSidebar'; import { NextcloudAPI } from './api/nextcloud'; import { Note } from './types'; @@ -12,6 +13,10 @@ function App() { const [selectedNoteId, setSelectedNoteId] = useState(null); const [searchText, setSearchText] = useState(''); const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); + const [selectedCategory, setSelectedCategory] = useState(''); + const [manualCategories, setManualCategories] = useState([]); + const [isCategoriesCollapsed, setIsCategoriesCollapsed] = useState(false); + const [isFocusMode, setIsFocusMode] = useState(false); const [fontSize] = useState(14); const [username, setUsername] = useState(''); const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system'); @@ -124,7 +129,7 @@ function App() { hour12: false, }).replace(/[/:]/g, '-').replace(', ', ' '); - const note = await api.createNote(`New Note ${timestamp}`, '', ''); + const note = await api.createNote(`New Note ${timestamp}`, '', selectedCategory); setNotes([note, ...notes]); setSelectedNoteId(note.id); } catch (error) { @@ -132,6 +137,12 @@ function App() { } }; + const handleCreateCategory = (name: string) => { + if (!manualCategories.includes(name)) { + setManualCategories([...manualCategories, name]); + } + }; + const handleUpdateNote = async (updatedNote: Note) => { if (!api) return; try { @@ -161,7 +172,11 @@ function App() { } }; + const categoriesFromNotes = Array.from(new Set(notes.map(n => n.category).filter(c => c))); + const categories = Array.from(new Set([...categoriesFromNotes, ...manualCategories])).sort(); + const filteredNotes = notes.filter(note => { + if (selectedCategory && note.category !== selectedCategory) return false; if (showFavoritesOnly && !note.favorite) return false; if (searchText) { const search = searchText.toLowerCase(); @@ -179,28 +194,43 @@ function App() { return (
- setShowFavoritesOnly(!showFavoritesOnly)} - hasUnsavedChanges={hasUnsavedChanges} - /> + {!isFocusMode && ( + <> + setIsCategoriesCollapsed(!isCategoriesCollapsed)} + username={username} + onLogout={handleLogout} + theme={theme} + onThemeChange={handleThemeChange} + /> + setShowFavoritesOnly(!showFavoritesOnly)} + hasUnsavedChanges={hasUnsavedChanges} + /> + + )} setIsFocusMode(!isFocusMode)} />
); diff --git a/src/components/CategoriesSidebar.tsx b/src/components/CategoriesSidebar.tsx new file mode 100644 index 0000000..b6db55c --- /dev/null +++ b/src/components/CategoriesSidebar.tsx @@ -0,0 +1,224 @@ +import { useState, useEffect, useRef } from 'react'; + +interface CategoriesSidebarProps { + categories: string[]; + selectedCategory: string; + onSelectCategory: (category: string) => void; + onCreateCategory: (name: string) => void; + isCollapsed: boolean; + onToggleCollapse: () => void; + username: string; + onLogout: () => void; + theme: 'light' | 'dark' | 'system'; + onThemeChange: (theme: 'light' | 'dark' | 'system') => void; +} + +export function CategoriesSidebar({ + categories, + selectedCategory, + onSelectCategory, + onCreateCategory, + isCollapsed, + onToggleCollapse, + username, + onLogout, + theme, + onThemeChange, +}: CategoriesSidebarProps) { + const [isCreating, setIsCreating] = useState(false); + const [newCategoryName, setNewCategoryName] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (isCreating && inputRef.current) { + inputRef.current.focus(); + } + }, [isCreating]); + + const handleCreateCategory = () => { + if (newCategoryName.trim()) { + onCreateCategory(newCategoryName.trim()); + setNewCategoryName(''); + setIsCreating(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleCreateCategory(); + } else if (e.key === 'Escape') { + setIsCreating(false); + setNewCategoryName(''); + } + }; + + if (isCollapsed) { + return ( + + ); + } + + return ( +
+
+
+

Categories

+ +
+ + +
+ +
+
+ + + {categories.map((category) => ( + + ))} + + {isCreating && ( +
+ + + + setNewCategoryName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (newCategoryName.trim()) { + handleCreateCategory(); + } else { + setIsCreating(false); + } + }} + placeholder="Category name..." + className="flex-1 text-sm px-0 py-0 border-none bg-transparent text-gray-900 dark:text-gray-100 focus:ring-0 focus:outline-none" + /> +
+ )} +
+
+ + {/* User Info and Settings */} +
+
+
+
+ {username.charAt(0).toUpperCase()} +
+ {username} +
+ +
+ + {/* Theme Toggle */} +
+ Theme +
+ + + +
+
+
+
+ ); +} diff --git a/src/components/CategorySelector.tsx b/src/components/CategorySelector.tsx new file mode 100644 index 0000000..c46e770 --- /dev/null +++ b/src/components/CategorySelector.tsx @@ -0,0 +1,116 @@ +import { useState, useEffect, useRef } from 'react'; + +interface CategorySelectorProps { + categories: string[]; + selectedCategory: string; + onSelectCategory: (category: string) => void; + onCreateCategory: (name: string) => void; +} + +export function CategorySelector({ + categories, + selectedCategory, + onSelectCategory, + onCreateCategory +}: CategorySelectorProps) { + const [isCreating, setIsCreating] = useState(false); + const [newCategoryName, setNewCategoryName] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (isCreating && inputRef.current) { + inputRef.current.focus(); + } + }, [isCreating]); + + const handleCreateCategory = () => { + if (newCategoryName.trim()) { + onCreateCategory(newCategoryName.trim()); + setNewCategoryName(''); + setIsCreating(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleCreateCategory(); + } else if (e.key === 'Escape') { + setIsCreating(false); + setNewCategoryName(''); + } + }; + + return ( +
+
+

+ Categories +

+ +
+ +
+ + + {categories.map((category) => ( + + ))} + + {isCreating && ( +
+ + + + setNewCategoryName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (!newCategoryName.trim()) { + setIsCreating(false); + } + }} + placeholder="Category name..." + className="flex-1 text-sm px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ )} +
+
+ ); +} diff --git a/src/components/FloatingToolbar.tsx b/src/components/FloatingToolbar.tsx new file mode 100644 index 0000000..2c7a4db --- /dev/null +++ b/src/components/FloatingToolbar.tsx @@ -0,0 +1,222 @@ +import { useEffect, useState, useRef, RefObject } from 'react'; + +type FormatType = 'bold' | 'italic' | 'strikethrough' | 'code' | 'codeblock' | 'quote' | 'ul' | 'ol' | 'link' | 'h1' | 'h2' | 'h3'; + +interface FloatingToolbarProps { + onFormat: (format: FormatType) => void; + textareaRef: RefObject; +} + +export function FloatingToolbar({ onFormat, textareaRef }: FloatingToolbarProps) { + const [position, setPosition] = useState<{ top: number; left: number } | null>(null); + const [isVisible, setIsVisible] = useState(false); + const [activeFormats, setActiveFormats] = useState>(new Set()); + const toolbarRef = useRef(null); + + const detectActiveFormats = (text: string, fullContent: string, selectionStart: number): Set => { + const formats = new Set(); + + // Check inline formats + if (/\*\*[^*]+\*\*/.test(text) || /__[^_]+__/.test(text)) formats.add('bold'); + if (/(?\s/.test(currentLine)) formats.add('quote'); + if (/^[-*+]\s/.test(currentLine)) formats.add('ul'); + if (/^\d+\.\s/.test(currentLine)) formats.add('ol'); + if (/\[.+\]\(.+\)/.test(text)) formats.add('link'); + + return formats; + }; + + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + const handleSelectionChange = () => { + const textarea = textareaRef.current; + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + + if (start === end) { + setIsVisible(false); + return; + } + + // Get textarea position and calculate approximate selection position + 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; + + // Get text before selection to calculate position + const textBeforeSelection = textarea.value.substring(0, start); + const lines = textBeforeSelection.split('\n'); + const currentLineIndex = lines.length - 1; + const currentLineText = lines[currentLineIndex]; + + // Approximate character width (monospace assumption) + const charWidth = fontSize * 0.6; + + // Calculate position + const scrollTop = textarea.scrollTop; + const top = textareaRect.top + paddingTop + (currentLineIndex * lineHeight) - scrollTop - 56; + const left = textareaRect.left + paddingLeft + (currentLineText.length * charWidth); + + const toolbarWidth = 320; + let adjustedLeft = Math.max(10, Math.min(left - toolbarWidth / 2, window.innerWidth - toolbarWidth - 10)); + let adjustedTop = top; + + if (adjustedTop < 10) { + adjustedTop = textareaRect.top + paddingTop + ((currentLineIndex + 1) * lineHeight) - scrollTop + 8; + } + + setPosition({ top: adjustedTop, left: adjustedLeft }); + setIsVisible(true); + + // Detect active formats + const selectedText = textarea.value.substring(start, end); + const formats = detectActiveFormats(selectedText, textarea.value, start); + setActiveFormats(formats); + }; + + const handleMouseUp = () => { + setTimeout(handleSelectionChange, 10); + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.shiftKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + handleSelectionChange(); + } + }; + + const handleBlur = () => { + // Delay hiding to allow button clicks to register + setTimeout(() => { + if (document.activeElement !== textarea && !toolbarRef.current?.contains(document.activeElement)) { + setIsVisible(false); + } + }, 150); + }; + + textarea.addEventListener('mouseup', handleMouseUp); + textarea.addEventListener('keyup', handleKeyUp); + textarea.addEventListener('blur', handleBlur); + textarea.addEventListener('select', handleSelectionChange); + + return () => { + textarea.removeEventListener('mouseup', handleMouseUp); + textarea.removeEventListener('keyup', handleKeyUp); + textarea.removeEventListener('blur', handleBlur); + textarea.removeEventListener('select', handleSelectionChange); + }; + }, [textareaRef]); + + if (!isVisible || !position) return null; + + const buttonClass = (format: FormatType) => `p-2 rounded transition-colors ${ + activeFormats.has(format) + ? 'bg-blue-500 text-white' + : 'hover:bg-gray-700 dark:hover:bg-gray-600 text-white' + }`; + + const headingButtonClass = (format: FormatType) => `px-2 py-1 rounded font-bold text-xs transition-colors ${ + activeFormats.has(format) + ? 'bg-blue-500 text-white' + : 'hover:bg-gray-700 dark:hover:bg-gray-600 text-white' + }`; + + return ( +
+ {/* Text Formatting */} + + + + + + +
+ + {/* Code */} + + + + +
+ + {/* Quote & Lists */} + + + + + + +
+ + {/* Link */} + + +
+ + {/* Headings */} + + + +
+ ); +} diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index 8bff95e..f158c97 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -1,117 +1,97 @@ import { useState, useEffect, useRef } from 'react'; -import { useEditor, EditorContent } from '@tiptap/react'; -import StarterKit from '@tiptap/starter-kit'; -import Underline from '@tiptap/extension-underline'; -import Strike from '@tiptap/extension-strike'; -import TurndownService from 'turndown'; import { marked } from 'marked'; import jsPDF from 'jspdf'; import { message } from '@tauri-apps/plugin-dialog'; import { Note } from '../types'; +import { FloatingToolbar } from './FloatingToolbar'; interface NoteEditorProps { note: Note | null; onUpdateNote: (note: Note) => void; fontSize: number; onUnsavedChanges?: (hasChanges: boolean) => void; + categories: string[]; + isFocusMode?: boolean; + onToggleFocusMode?: () => void; } -const turndownService = new TurndownService({ - headingStyle: 'atx', - codeBlockStyle: 'fenced', -}); -export function NoteEditor({ note, onUpdateNote, fontSize, onUnsavedChanges }: NoteEditorProps) { +export function NoteEditor({ note, onUpdateNote, fontSize, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode }: NoteEditorProps) { const [localTitle, setLocalTitle] = useState(''); + const [localContent, setLocalContent] = useState(''); + const [localCategory, setLocalCategory] = useState(''); const [localFavorite, setLocalFavorite] = useState(false); const [isSaving, setIsSaving] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [titleManuallyEdited, setTitleManuallyEdited] = useState(false); const [isExportingPDF, setIsExportingPDF] = useState(false); + const [isPreviewMode, setIsPreviewMode] = useState(false); const previousNoteIdRef = useRef(null); + const textareaRef = useRef(null); - // Notify parent component when unsaved changes state changes useEffect(() => { onUnsavedChanges?.(hasUnsavedChanges); }, [hasUnsavedChanges, onUnsavedChanges]); - const editor = useEditor({ - extensions: [ - StarterKit, - Underline, - Strike, - ], - content: '', - editorProps: { - attributes: { - class: 'prose prose-slate max-w-none focus:outline-none p-8', - style: `font-size: ${fontSize}px`, - }, - }, - onUpdate: ({ editor }) => { - setHasUnsavedChanges(true); - - if (!titleManuallyEdited) { - const text = editor.getText(); - const firstLine = text.split('\n')[0].trim(); - if (firstLine) { - setLocalTitle(firstLine.substring(0, 50)); - } + // 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 + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; + } + }, [localContent]); useEffect(() => { const loadNewNote = () => { - if (note && editor) { + if (note) { setLocalTitle(note.title); + setLocalContent(note.content); + setLocalCategory(note.category || ''); setLocalFavorite(note.favorite); setHasUnsavedChanges(false); - // Only reset titleManuallyEdited when switching to a different note - // Check if the current title matches the first line of content const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim(); const titleMatchesFirstLine = note.title === firstLine || note.title === firstLine.substring(0, 50); setTitleManuallyEdited(!titleMatchesFirstLine); previousNoteIdRef.current = note.id; - - // Convert markdown to HTML using marked library - const html = marked.parse(note.content || '', { async: false }) as string; - editor.commands.setContent(html); } }; - // If switching notes, save the previous note first if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) { - // Save if there are unsaved changes - if (hasUnsavedChanges && editor) { + if (hasUnsavedChanges) { handleSave(); } - // Load new note after a brief delay to ensure save completes setTimeout(loadNewNote, 100); } else { - // First load or same note, load immediately loadNewNote(); } - }, [note?.id, editor]); + }, [note?.id]); const handleSave = () => { - if (!note || !hasUnsavedChanges || !editor) return; + if (!note || !hasUnsavedChanges) return; - // Convert HTML to markdown - const html = editor.getHTML(); - const markdown = turndownService.turndown(html); - - console.log('Saving note content length:', markdown.length); - console.log('Last 50 chars:', markdown.slice(-50)); + console.log('Saving note content length:', localContent.length); + console.log('Last 50 chars:', localContent.slice(-50)); setIsSaving(true); setHasUnsavedChanges(false); onUpdateNote({ ...note, title: localTitle, - content: markdown, - category: '', + content: localContent, + category: localCategory, favorite: localFavorite, }); setTimeout(() => setIsSaving(false), 500); @@ -123,43 +103,44 @@ export function NoteEditor({ note, onUpdateNote, fontSize, onUnsavedChanges }: N setHasUnsavedChanges(true); }; - const handleDiscard = () => { - if (!note || !editor) return; + const handleContentChange = (value: string) => { + setLocalContent(value); + setHasUnsavedChanges(true); + + if (!titleManuallyEdited) { + const firstLine = value.split('\n')[0].replace(/^#+\s*/, '').trim(); + if (firstLine) { + setLocalTitle(firstLine.substring(0, 50)); + } + } + }; + + const handleDiscard = () => { + if (!note) return; - // Reload original note content setLocalTitle(note.title); + setLocalContent(note.content); + setLocalCategory(note.category || ''); setLocalFavorite(note.favorite); setHasUnsavedChanges(false); const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim(); const titleMatchesFirstLine = note.title === firstLine || note.title === firstLine.substring(0, 50); setTitleManuallyEdited(!titleMatchesFirstLine); - - const html = marked.parse(note.content || '', { async: false }) as string; - editor.commands.setContent(html); }; const handleExportPDF = async () => { - if (!note || !editor) return; + if (!note) return; setIsExportingPDF(true); try { - // Get the editor content element - const editorElement = document.querySelector('.ProseMirror'); - if (!editorElement) { - setIsExportingPDF(false); - return; - } - - // Create a container with title and content const container = document.createElement('div'); container.style.fontFamily = 'Arial, sans-serif'; container.style.fontSize = '12px'; container.style.lineHeight = '1.6'; container.style.color = '#000000'; - // Add title const titleElement = document.createElement('h1'); titleElement.textContent = localTitle || 'Untitled'; titleElement.style.marginTop = '0'; @@ -170,12 +151,13 @@ export function NoteEditor({ note, onUpdateNote, fontSize, onUnsavedChanges }: N titleElement.style.textAlign = 'center'; container.appendChild(titleElement); - // Clone and add content - const contentClone = editorElement.cloneNode(true) as HTMLElement; - contentClone.style.fontSize = '12px'; - contentClone.style.lineHeight = '1.6'; - contentClone.style.color = '#000000'; - container.appendChild(contentClone); + const contentElement = document.createElement('div'); + const html = marked.parse(localContent || '', { async: false }) as string; + contentElement.innerHTML = html; + contentElement.style.fontSize = '12px'; + contentElement.style.lineHeight = '1.6'; + contentElement.style.color = '#000000'; + container.appendChild(contentElement); // Create PDF using jsPDF's html() method (like dompdf) const pdf = new jsPDF({ @@ -229,16 +211,161 @@ export function NoteEditor({ note, onUpdateNote, fontSize, onUnsavedChanges }: N onUpdateNote({ ...note, title: localTitle, - content: editor ? turndownService.turndown(editor.getHTML()) : note.content, - category: '', + content: localContent, + category: localCategory, favorite: !localFavorite, }); } }; + const handleCategoryChange = (category: string) => { + setLocalCategory(category); + setHasUnsavedChanges(true); + }; + + 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 ( -
+
@@ -250,90 +377,28 @@ export function NoteEditor({ note, onUpdateNote, fontSize, onUnsavedChanges }: N ); } - if (!editor) { - return null; - } - return (
-
- handleTitleChange(e.target.value)} - placeholder="Note Title" - className="flex-1 text-2xl font-bold border-none outline-none focus:ring-0 bg-transparent text-gray-900 dark:text-gray-100" - /> - -
- {hasUnsavedChanges && ( - Unsaved changes - )} - {isSaving && ( - Saving... - )} + {/* Header */} +
+
+
+ handleTitleChange(e.target.value)} + placeholder="Note Title" + className="w-full text-2xl font-semibold border-none outline-none focus:ring-0 bg-transparent text-gray-900 dark:text-gray-100 placeholder-gray-400" + /> +
- - - - - -
- {/* Formatting Toolbar */} -
- + {/* Toolbar */} +
+
+
+ {/* Category Selector */} +
+ + + + + + + +
- + {/* Preview Toggle */} + +
- +
+ {/* Status */} + {(hasUnsavedChanges || isSaving) && ( + + {isSaving ? 'Saving...' : 'Unsaved'} + + )} - + {/* Action Buttons */} +
+ -
+ + + - - - - - - -
- - - - - -
- - - - + {/* Focus Mode Toggle */} + {onToggleFocusMode && ( + + )} +
+
+
-
- +
+
+ {isPreviewMode ? ( +
+ ) : ( +
+ +