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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user