diff --git a/memento-note/app/api/ai/reformulate/route.ts b/memento-note/app/api/ai/reformulate/route.ts index 3924fa0..0e3143b 100644 --- a/memento-note/app/api/ai/reformulate/route.ts +++ b/memento-note/app/api/ai/reformulate/route.ts @@ -17,7 +17,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Feature disabled' }, { status: 403 }) } - const { text, option } = await request.json() + const { text, option, format } = await request.json() // Validation if (!text || typeof text !== 'string') { @@ -50,7 +50,7 @@ export async function POST(request: NextRequest) { } // Use the ParagraphRefactorService - const result = await paragraphRefactorService.refactor(text, mode) + const result = await paragraphRefactorService.refactor(text, mode, format === 'html' ? 'html' : 'markdown') return NextResponse.json({ originalText: result.original, diff --git a/memento-note/components/note-input.tsx b/memento-note/components/note-input.tsx index 66087c6..1df1e27 100644 --- a/memento-note/components/note-input.tsx +++ b/memento-note/components/note-input.tsx @@ -271,7 +271,7 @@ export function NoteInput({ const response = await fetch('/api/ai/reformulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text: content, option: 'clarify' }) + body: JSON.stringify({ text: content, option: 'clarify', format: type === 'richtext' ? 'html' : 'markdown' }) }) const data = await response.json() if (!response.ok) throw new Error(data.error || 'Failed to clarify') @@ -297,7 +297,7 @@ export function NoteInput({ const response = await fetch('/api/ai/reformulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text: content, option: 'shorten' }) + body: JSON.stringify({ text: content, option: 'shorten', format: type === 'richtext' ? 'html' : 'markdown' }) }) const data = await response.json() if (!response.ok) throw new Error(data.error || 'Failed to shorten') @@ -323,7 +323,7 @@ export function NoteInput({ const response = await fetch('/api/ai/reformulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text: content, option: 'improve' }) + body: JSON.stringify({ text: content, option: 'improve', format: type === 'richtext' ? 'html' : 'markdown' }) }) const data = await response.json() if (!response.ok) throw new Error(data.error || 'Failed to improve') diff --git a/memento-note/components/rich-text-editor.tsx b/memento-note/components/rich-text-editor.tsx index 1f67ffc..aa161e3 100644 --- a/memento-note/components/rich-text-editor.tsx +++ b/memento-note/components/rich-text-editor.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHand import { createPortal } from 'react-dom' import { useLanguage } from '@/lib/i18n' import { useEditor, EditorContent } from '@tiptap/react' -import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus' +import { BubbleMenu } from '@tiptap/react/menus' import StarterKit from '@tiptap/starter-kit' import Underline from '@tiptap/extension-underline' import Placeholder from '@tiptap/extension-placeholder' @@ -52,6 +52,21 @@ type SlashItem = { command: (editor: Editor) => void } +const CustomImage = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: '100%', + renderHTML: attributes => { + if (!attributes.width) return {} + return { style: `width: ${attributes.width}; max-width: 100%; height: auto;` } + } + } + } + } +}) + const slashCommands: SlashItem[] = [ // Basic blocks (indices 0-9) { title: 'Text', description: 'Plain paragraph', icon: Pilcrow, category: 'Basic blocks', shortcut: 'ΒΆ', command: (e) => e.chain().focus().setParagraph().run() }, @@ -81,7 +96,7 @@ async function aiReformulate(text: string, option: 'clarify' | 'shorten' | 'impr const res = await fetch('/api/ai/reformulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text, option }), + body: JSON.stringify({ text, option, format: 'html' }), }) const data = await res.json() if (!res.ok) throw new Error(data.error || 'AI failed') @@ -124,8 +139,8 @@ export const RichTextEditor = forwardRef { - onChange?.(e.getHTML()) + const html = e.getHTML() + lastEmittedContent.current = html + onChange?.(html) }, }) + const lastEmittedContent = useRef(content || '') + + useEffect(() => { + if (editor && content !== undefined && content !== lastEmittedContent.current) { + editor.commands.setContent(content || '') + lastEmittedContent.current = content || '' + } + }, [content, editor]) + useImperativeHandle(ref, () => ({ getEditor: () => editor }), [editor]) return ( @@ -153,26 +179,14 @@ export const RichTextEditor = forwardRef { const { from, to } = state.selection - return from !== to && !e.isActive('codeBlock') + const isImage = e.isActive('image') + return (from !== to && !e.isActive('codeBlock')) || isImage }} > )} - {editor && ( - - - - )} - {editor && } @@ -186,6 +200,7 @@ export const RichTextEditor = forwardRef void; onCancel: () => void }) { + const { t } = useLanguage() const [url, setUrl] = useState('') const [preview, setPreview] = useState(null) const [error, setError] = useState('') @@ -195,14 +210,14 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void; const handleConfirm = () => { if (!url.trim()) return - if (!/^https?:\/\/.+/i.test(url.trim())) { setError('Please enter a valid URL'); return } + if (!/^https?:\/\/.+/i.test(url.trim())) { setError(t('richTextEditor.imageModalInvalidUrl')); return } onConfirm(url.trim()) } return (
e.stopPropagation()}> -
Insert image
+
{t('richTextEditor.imageModalTitle')}
void; /> {url.trim() && !preview && ( )} {preview && (
- { setPreview(null); setError('Failed to load image') }} /> + { setPreview(null); setError(t('richTextEditor.imageModalLoadFailed')) }} />
)} {error &&
{error}
}
- - + +
@@ -308,7 +323,7 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) { value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); applyLink() }; if (e.key === 'Escape') { setLinkOpen(false); editor.chain().focus().run() } }} - placeholder="Paste or type a link..." + placeholder={t('richTextEditor.linkPlaceholder')} className="notion-inline-input" /> @@ -332,6 +347,14 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
+ {editor.isActive('image') && ( + <> +
+ + + + + )} {aiOpen && (
@@ -423,6 +446,14 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase())) const filtered = activeCategory ? textFiltered.filter(c => (c.category || 'Basic blocks') === activeCategory) : textFiltered + // Compute categories based on full search to keep tabs visible even when one is selected + const availableCategoriesInSearch = textFiltered.reduce((acc, item) => { + const cat = item.category || 'Basic blocks' + if (!acc[cat]) acc[cat] = [] + acc[cat].push(item) + return acc + }, {} as Record) + const categories = filtered.reduce((acc, item) => { const cat = item.category || 'Basic blocks' if (!acc[cat]) acc[cat] = [] @@ -440,16 +471,29 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI if (!isOpen) return if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => (i + 1) % filtered.length); return } if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length); return } - if (e.key === 'Enter') { e.preventDefault(); if (filtered[selectedIndex]) handleSelect(filtered[selectedIndex]); return } + if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { + e.preventDefault() + const availableTabs = [null, ...allCategories.filter(cat => availableCategoriesInSearch[cat])] + const currentIndex = availableTabs.indexOf(activeCategory) + const nextIndex = e.key === 'ArrowRight' + ? (currentIndex + 1) % availableTabs.length + : (currentIndex - 1 + availableTabs.length) % availableTabs.length + setActiveCategory(availableTabs[nextIndex]) + setSelectedIndex(0) + return + } + if (e.key === 'Enter') { + e.preventDefault() + const item = filtered[selectedIndex] + if (item) handleSelect(item) + return + } if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return } if (e.key === 'Tab') { e.preventDefault() - // Cycle through categories with Tab - const catKeys = Object.keys(categories) - if (catKeys.length < 2) return - const currentCatIdx = activeCategory ? catKeys.indexOf(activeCategory) : -1 - const nextCat = catKeys[(currentCatIdx + 1) % catKeys.length] - setActiveCategory(nextCat) + const availableTabs = [null, ...allCategories.filter(cat => availableCategoriesInSearch[cat])] + const nextIndex = (availableTabs.indexOf(activeCategory) + 1) % availableTabs.length + setActiveCategory(availableTabs[nextIndex]) setSelectedIndex(0) return } @@ -517,7 +561,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI > {t('richTextEditor.slashTabAll')} - {allCategories.filter(cat => categories[cat]).map(cat => ( + {allCategories.filter(cat => availableCategoriesInSearch[cat]).map(cat => ( ))}
diff --git a/memento-note/lib/ai/services/paragraph-refactor.service.ts b/memento-note/lib/ai/services/paragraph-refactor.service.ts index 867b537..c182e21 100644 --- a/memento-note/lib/ai/services/paragraph-refactor.service.ts +++ b/memento-note/lib/ai/services/paragraph-refactor.service.ts @@ -67,7 +67,8 @@ export class ParagraphRefactorService { */ async refactor( content: string, - mode: RefactorMode + mode: RefactorMode, + format: 'html' | 'markdown' = 'markdown' ): Promise { // Validate word count const wordCount = content.split(/\s+/).length @@ -82,8 +83,8 @@ export class ParagraphRefactorService { try { // Build prompts - const systemPrompt = this.getSystemPrompt(mode) - const userPrompt = this.getUserPrompt(mode, content, language) + const systemPrompt = this.getSystemPrompt(mode, format) + const userPrompt = this.getUserPrompt(mode, content, language, format) // Get AI provider from factory const config = await getSystemConfig() @@ -210,25 +211,29 @@ IMPORTANT: Provide all 3 versions in ${language}. No English, no explanations.` /** * Get mode-specific system prompt */ - private getSystemPrompt(mode: RefactorMode): string { + private getSystemPrompt(mode: RefactorMode, format: 'html' | 'markdown' = 'markdown'): string { + const formatInstruction = format === 'html' + ? "\nCRITICAL FORMAT RULE: You MUST return your response as valid HTML fragments (e.g., using

, , ,

    , etc.) without markdown symbols. Do not wrap in a markdown code block." + : "\nCRITICAL FORMAT RULE: You MUST return your response as plain text or Markdown." + const prompts = { clarify: `You are an expert at making text clearer and more understandable. Your goal: Rewrite the text to eliminate ambiguity, add necessary context, and improve clarity. CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. If input is French, output MUST be French. If input is German, output MUST be German. NEVER translate to English. -Maintain the original meaning and tone, just make it clearer.`, +Maintain the original meaning and tone, just make it clearer.${formatInstruction}`, shorten: `You are an expert at concise writing. Your goal: Reduce the text length by 30-50% while preserving ALL key information and meaning. CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. If input is French, output MUST be French. If input is German, output MUST be German. NEVER translate to English. -Remove fluff, repetition, and unnecessary words, but keep the substance.`, +Remove fluff, repetition, and unnecessary words, but keep the substance.${formatInstruction}`, improveStyle: `You are an expert editor with a focus on readability and flow. Your goal: Enhance the text's style, vocabulary, sentence structure, and overall quality. CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. If input is French, output MUST be French. If input is German, output MUST be German. NEVER translate to English. -Maintain similar length but make it sound more professional and polished.` +Maintain similar length but make it sound more professional and polished.${formatInstruction}` } return prompts[mode] @@ -237,7 +242,7 @@ Maintain similar length but make it sound more professional and polished.` /** * Get mode-specific user prompt */ - private getUserPrompt(mode: RefactorMode, content: string, language: string): string { + private getUserPrompt(mode: RefactorMode, content: string, language: string, format: 'html' | 'markdown' = 'markdown'): string { const instructions = { clarify: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English. @@ -256,12 +261,12 @@ Please improve the style and readability of this ${language} text:` ${content} -CRITICAL: Respond ONLY with the refactored text in ${language}. No explanations, no meta-commentary, no English.` +CRITICAL: Respond ONLY with the refactored text in ${language}. No explanations, no meta-commentary, no English. Format strictly as ${format === 'html' ? 'HTML tags' : 'Markdown'}.` } /** * Extract refactored text from AI response - * Handles JSON, markdown code blocks, or plain text + * Handles JSON, markdown code blocks, or HTML/plain text */ private extractRefactoredText(response: string): string { // Try JSON first @@ -275,12 +280,18 @@ CRITICAL: Respond ONLY with the refactored text in ${language}. No explanations, } } - // Try markdown code block - const codeBlockMatch = response.match(/```(?:markdown)?\n([\s\S]+?)\n```/) + // Try markdown code block (HTML or Markdown) + const codeBlockMatch = response.match(/```(?:markdown|html)?\n([\s\S]+?)\n```/) if (codeBlockMatch) { return codeBlockMatch[1].trim() } + // Try bare HTML if wrapped in a single code block + const genericCodeBlockMatch = response.match(/```\n([\s\S]+?)\n```/) + if (genericCodeBlockMatch) { + return genericCodeBlockMatch[1].trim() + } + // Fallback: trim whitespace and quotes return response.trim().replace(/^["']|["']$/g, '') }