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

This commit is contained in:
2026-05-02 23:31:40 +02:00
parent 6d6e12ba05
commit e4ca7ba497
4 changed files with 107 additions and 52 deletions

View File

@@ -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')

View File

@@ -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>