feat: slash menu navigation, removed gutter button, fixed AI richtext format, added image resize
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 44s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 44s
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<RichTextEditorHandle, RichTextEditorPro
|
||||
Underline,
|
||||
TiptapLink.configure({ openOnClick: false, autolink: true }),
|
||||
Highlight.configure({ multicolor: false }),
|
||||
Image.configure({ inline: false, allowBase64: true }),
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
CustomImage.configure({ inline: false, allowBase64: true }),
|
||||
TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Superscript,
|
||||
@@ -139,10 +154,21 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
attributes: { class: 'notion-editor' },
|
||||
},
|
||||
onUpdate: ({ editor: e }) => {
|
||||
onChange?.(e.getHTML())
|
||||
const html = e.getHTML()
|
||||
lastEmittedContent.current = html
|
||||
onChange?.(html)
|
||||
},
|
||||
})
|
||||
|
||||
const lastEmittedContent = useRef<string>(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<RichTextEditorHandle, RichTextEditorPro
|
||||
className="notion-bubble-menu"
|
||||
shouldShow={({ editor: e, state }: { editor: Editor; state: EditorState }) => {
|
||||
const { from, to } = state.selection
|
||||
return from !== to && !e.isActive('codeBlock')
|
||||
const isImage = e.isActive('image')
|
||||
return (from !== to && !e.isActive('codeBlock')) || isImage
|
||||
}}
|
||||
>
|
||||
<BubbleToolbar editor={editor} />
|
||||
</BubbleMenu>
|
||||
)}
|
||||
|
||||
{editor && (
|
||||
<FloatingMenu editor={editor} className="flex items-center -ml-12" tippyOptions={{ placement: 'left' }}>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().insertContent('/').run()}
|
||||
className="flex items-center justify-center w-6 h-6 rounded-full text-muted-foreground/40 bg-transparent hover:bg-background hover:text-foreground hover:shadow-md hover:scale-110 active:scale-95 transition-all duration-300 group ring-1 ring-transparent hover:ring-border/50"
|
||||
title={t('richTextEditor.addBlock') || 'Add block'}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="w-4 h-4 group-hover:rotate-90 transition-transform duration-300" />
|
||||
</button>
|
||||
</FloatingMenu>
|
||||
)}
|
||||
|
||||
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} />}
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
@@ -186,6 +200,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
)
|
||||
|
||||
function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void; onCancel: () => void }) {
|
||||
const { t } = useLanguage()
|
||||
const [url, setUrl] = useState('')
|
||||
const [preview, setPreview] = useState<string | null>(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 (
|
||||
<div className="notion-overlay" onClick={onCancel}>
|
||||
<div className="notion-image-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="text-sm font-medium mb-3">Insert image</div>
|
||||
<div className="text-sm font-medium mb-3">{t('richTextEditor.imageModalTitle')}</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
@@ -214,18 +229,18 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void;
|
||||
/>
|
||||
{url.trim() && !preview && (
|
||||
<button onClick={() => setPreview(url.trim())} className="text-xs text-muted-foreground hover:text-foreground transition-colors underline mt-2">
|
||||
Preview
|
||||
{t('richTextEditor.imageModalPreview')}
|
||||
</button>
|
||||
)}
|
||||
{preview && (
|
||||
<div className="mt-2 rounded-lg overflow-hidden border border-border max-h-40">
|
||||
<img src={preview} alt="" className="max-h-40 object-contain w-full" onError={() => { setPreview(null); setError('Failed to load image') }} />
|
||||
<img src={preview} alt="" className="max-h-40 object-contain w-full" onError={() => { setPreview(null); setError(t('richTextEditor.imageModalLoadFailed')) }} />
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mt-1.5 text-xs text-destructive">{error}</div>}
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={onCancel} className="notion-modal-btn">Cancel</button>
|
||||
<button onClick={handleConfirm} className="notion-modal-btn notion-modal-btn-primary">Insert</button>
|
||||
<button onClick={onCancel} className="notion-modal-btn">{t('richTextEditor.imageModalCancel')}</button>
|
||||
<button onClick={handleConfirm} className="notion-modal-btn notion-modal-btn-primary">{t('richTextEditor.imageModalInsert')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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"
|
||||
/>
|
||||
<button onClick={applyLink} className="notion-bubble-btn notion-bubble-btn-active"><Check className="w-3.5 h-3.5" /></button>
|
||||
@@ -332,6 +347,14 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
|
||||
<button onClick={openLinkEditor} className={cn('notion-bubble-btn', editor.isActive('link') && 'notion-bubble-btn-active')}><LinkIcon className="w-3.5 h-3.5" /></button>
|
||||
<button onClick={() => setAiOpen(!aiOpen)} className={cn('notion-bubble-btn', aiLoading && 'animate-pulse')}><Sparkles className="w-3.5 h-3.5" /></button>
|
||||
{editor.isActive('image') && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
|
||||
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()} className="notion-bubble-btn text-xs font-medium px-1">25%</button>
|
||||
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()} className="notion-bubble-btn text-xs font-medium px-1">50%</button>
|
||||
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()} className="notion-bubble-btn text-xs font-medium px-1">100%</button>
|
||||
</>
|
||||
)}
|
||||
{aiOpen && (
|
||||
<div className="notion-ai-submenu">
|
||||
<button className="notion-ai-subitem" onClick={() => handleAI('clarify')}><Lightbulb className="w-3.5 h-3.5 text-amber-500" /><span>{t('richTextEditor.slashClarify')}</span></button>
|
||||
@@ -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<string, SlashItem[]>)
|
||||
|
||||
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')}
|
||||
</button>
|
||||
{allCategories.filter(cat => categories[cat]).map(cat => (
|
||||
{allCategories.filter(cat => availableCategoriesInSearch[cat]).map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
className={cn('notion-slash-tab', activeCategory === cat && 'notion-slash-tab-active', cat === 'IA Note' && 'notion-slash-tab-ai')}
|
||||
@@ -526,7 +570,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
>
|
||||
{cat === 'IA Note' && <Sparkles className="w-2.5 h-2.5" />}
|
||||
{cat}
|
||||
<span className="notion-slash-tab-count">{categories[cat]?.length ?? 0}</span>
|
||||
<span className="notion-slash-tab-count">{availableCategoriesInSearch[cat]?.length ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,8 @@ export class ParagraphRefactorService {
|
||||
*/
|
||||
async refactor(
|
||||
content: string,
|
||||
mode: RefactorMode
|
||||
mode: RefactorMode,
|
||||
format: 'html' | 'markdown' = 'markdown'
|
||||
): Promise<RefactorResult> {
|
||||
// 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 <p>, <strong>, <em>, <ul>, 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, '')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user