feat(editor): AI modal preview/apply, translate lang picker, fix p-tag injection, explain modal - no UTF-8 corruption
This commit is contained in:
@@ -1139,6 +1139,61 @@ html.font-system * {
|
||||
.notion-ai-subitem:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
.notion-ai-lang-picker {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
padding: 6px;
|
||||
}
|
||||
.notion-ai-lang-item {
|
||||
font-size: 0.72rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
color: var(--foreground);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.notion-ai-lang-item:hover { background: var(--accent); }
|
||||
.notion-ai-result-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
z-index: 99998;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.notion-ai-result-modal {
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
|
||||
padding: 20px;
|
||||
width: min(520px, 90vw);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dark .notion-ai-result-modal { box-shadow: 0 20px 60px rgba(0,0,0,0.6); }
|
||||
.notion-ai-result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.notion-ai-result-section { margin-bottom: 12px; }
|
||||
.notion-ai-result-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
|
||||
/* Inline input inside bubble menu (link editor) */
|
||||
.notion-inline-input {
|
||||
|
||||
@@ -114,6 +114,9 @@ export function ContextualAIChat({
|
||||
// Action state
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null)
|
||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||
const [translateTarget, setTranslateTarget] = useState('')
|
||||
const [customLangInput, setCustomLangInput] = useState('')
|
||||
|
||||
// Resource tab state
|
||||
const [resourceUrl, setResourceUrl] = useState('')
|
||||
@@ -170,7 +173,7 @@ export function ContextualAIChat({
|
||||
}
|
||||
|
||||
// ── Action execution ────────────────────────────────────────────────────────
|
||||
const handleAction = async (action: ActionDef) => {
|
||||
const handleAction = async (action: ActionDef, targetLang?: string) => {
|
||||
// Image-specific action
|
||||
if (action.isImageAction) {
|
||||
if (!noteImages || noteImages.length === 0) {
|
||||
@@ -216,7 +219,7 @@ export function ContextualAIChat({
|
||||
const res = await fetch(action.apiPath, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(action.body(noteContent, undefined, language)),
|
||||
body: JSON.stringify(action.body(noteContent, undefined, targetLang || language)),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
@@ -716,6 +719,49 @@ export function ContextualAIChat({
|
||||
ACTION_IDS.filter(a => !a.isImageAction).map(action => {
|
||||
const Icon = action.icon
|
||||
const loading = actionLoading === action.id
|
||||
if (action.id === 'translate') {
|
||||
return (
|
||||
<div key={action.id} className="flex flex-col gap-1.5">
|
||||
<button
|
||||
onClick={() => setShowLangPicker(v => !v)}
|
||||
disabled={!!actionLoading}
|
||||
className="w-full flex items-center gap-3 rounded-xl border border-border/60 bg-card px-4 py-3 text-sm font-medium text-foreground hover:bg-muted hover:border-primary/40 transition-all text-left disabled:opacity-60"
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 text-primary animate-spin shrink-0" /> : <Icon className="h-4 w-4 text-primary shrink-0" />}
|
||||
<span>{t(action.i18nKey)}</span>
|
||||
{translateTarget && <span className="ml-auto text-xs text-primary/70 font-normal">{translateTarget}</span>}
|
||||
</button>
|
||||
{showLangPicker && (
|
||||
<div className="flex flex-col gap-2 px-3 py-3 rounded-xl border border-border/40 bg-muted/30">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{['Francais','English','Espanol','Deutsch','Arabe','Portugais','Italiano','Chinois','Japonais'].map(l => (
|
||||
<button
|
||||
key={l}
|
||||
className={`text-xs px-2.5 py-1 rounded-lg border transition-colors ${translateTarget === l ? 'bg-primary text-primary-foreground border-primary' : 'bg-card border-border hover:bg-accent'}`}
|
||||
onClick={() => setTranslateTarget(l)}
|
||||
>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="text-xs px-3 py-1.5 rounded-lg border border-border bg-card outline-none focus:border-primary w-full"
|
||||
placeholder={t('ai.action.customLang') || 'Autre langue...'}
|
||||
value={customLangInput}
|
||||
onChange={e => setCustomLangInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && customLangInput.trim()) { setTranslateTarget(customLangInput.trim()); setCustomLangInput('') } }}
|
||||
/>
|
||||
<button
|
||||
disabled={!!actionLoading || !translateTarget}
|
||||
onClick={() => handleAction(action, translateTarget)}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50 hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Languages className="h-3 w-3" />}
|
||||
{t('ai.action.translate')} {translateTarget}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
|
||||
@@ -276,6 +276,8 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void;
|
||||
)
|
||||
}
|
||||
|
||||
const AI_LANGS = ['Francais','English','Espanol','Deutsch','Arabe','Portugais','Italiano','Chinois','Japonais']
|
||||
|
||||
function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
const { t, language } = useLanguage()
|
||||
const [, setTick] = useState(0)
|
||||
@@ -284,6 +286,9 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
const [linkOpen, setLinkOpen] = useState(false)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const linkInputRef = useRef<HTMLInputElement>(null)
|
||||
const [translateOpen, setTranslateOpen] = useState(false)
|
||||
const [customLang, setCustomLang] = useState('')
|
||||
const [aiModal, setAiModal] = useState<{ type: 'explain' | 'preview'; origText: string; html: string; from: number; to: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
@@ -310,18 +315,20 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
{ icon: SubscriptIcon, active: editor.isActive('subscript'), action: () => editor.chain().focus().toggleSubscript().run(), title: t('richTextEditor.subscript') },
|
||||
]
|
||||
|
||||
const handleAI = async (option: 'clarify' | 'shorten' | 'improve' | 'fix_grammar' | 'translate' | 'explain') => {
|
||||
const handleAI = async (option: 'clarify' | 'shorten' | 'improve' | 'fix_grammar' | 'translate' | 'explain', targetLang?: string) => {
|
||||
const { from, to } = editor.state.selection
|
||||
const text = editor.state.doc.textBetween(from, to, ' ')
|
||||
if (!text || text.split(/\s+/).length < 2) return
|
||||
setAiLoading(true)
|
||||
setAiOpen(false)
|
||||
setTranslateOpen(false)
|
||||
try {
|
||||
const result = await aiReformulate(text, option, language)
|
||||
const lang = option === 'translate' ? (targetLang || language) : language
|
||||
const result = await aiReformulate(text, option, lang)
|
||||
if (option === 'explain') {
|
||||
toast.message(t('ai.action.explain'), { description: result, duration: 10000 })
|
||||
setAiModal({ type: 'explain', origText: text, html: result, from, to })
|
||||
} else {
|
||||
editor.chain().focus().insertContentAt({ from, to }, result).run()
|
||||
setAiModal({ type: 'preview', origText: text, html: result, from, to })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AI error:', err)
|
||||
@@ -330,6 +337,13 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
}
|
||||
}
|
||||
|
||||
const applyAiResult = () => {
|
||||
if (!aiModal || !editor) { setAiModal(null); return }
|
||||
const clean = aiModal.html.replace(/^<p>([\s\S]*)<\/p>$/, '$1').trim()
|
||||
editor.chain().focus().insertContentAt({ from: aiModal.from, to: aiModal.to }, clean).run()
|
||||
setAiModal(null)
|
||||
}
|
||||
|
||||
const openLinkEditor = () => {
|
||||
const existing = editor.getAttributes('link').href || ''
|
||||
setLinkUrl(existing)
|
||||
@@ -394,10 +408,54 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
<button className="notion-ai-subitem" onClick={() => handleAI('shorten')}><Scissors className="w-3.5 h-3.5 text-blue-500" /><span>{t('richTextEditor.slashShorten')}</span></button>
|
||||
<button className="notion-ai-subitem" onClick={() => handleAI('improve')}><Wand2 className="w-3.5 h-3.5 text-purple-500" /><span>{t('richTextEditor.slashImprove')}</span></button>
|
||||
<button className="notion-ai-subitem" onClick={() => handleAI('fix_grammar')}><SpellCheck className="w-3.5 h-3.5 text-green-500" /><span>{t('ai.action.fixGrammar')}</span></button>
|
||||
<button className="notion-ai-subitem" onClick={() => handleAI('translate')}><Languages className="w-3.5 h-3.5 text-indigo-500" /><span>{t('ai.action.translate')}</span></button>
|
||||
<button className="notion-ai-subitem" onClick={() => setTranslateOpen(v => !v)}><Languages className="w-3.5 h-3.5 text-indigo-500" /><span>{t('ai.action.translate')}</span></button>
|
||||
{translateOpen && (
|
||||
<div className="notion-ai-lang-picker">
|
||||
{AI_LANGS.map(l => (
|
||||
<button key={l} className="notion-ai-lang-item" onClick={() => handleAI('translate', l)}>{l}</button>
|
||||
))}
|
||||
<div className="flex items-center gap-1 px-1 pt-1 w-full border-t border-border/40 mt-1">
|
||||
<input
|
||||
className="notion-inline-input text-xs flex-1 min-w-0"
|
||||
placeholder={t('ai.action.customLang') || 'Autre...'}
|
||||
value={customLang}
|
||||
onChange={e => setCustomLang(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && customLang.trim()) handleAI('translate', customLang.trim()) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button className="notion-ai-subitem" onClick={() => handleAI('explain')}><BookOpen className="w-3.5 h-3.5 text-orange-500" /><span>{t('ai.action.explain')}</span></button>
|
||||
</div>
|
||||
)}
|
||||
{aiModal && (
|
||||
<div className="notion-ai-result-overlay" onClick={() => setAiModal(null)}>
|
||||
<div className="notion-ai-result-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="notion-ai-result-header">
|
||||
<span className="text-sm font-semibold">
|
||||
{aiModal.type === 'explain' ? t('ai.action.explain') : (t('ai.result.preview') || 'Apercu IA')}
|
||||
</span>
|
||||
<button onClick={() => setAiModal(null)} className="notion-bubble-btn ml-auto"><X className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
{aiModal.type === 'preview' && (
|
||||
<div className="notion-ai-result-section">
|
||||
<span className="notion-ai-result-label">{t('ai.result.original') || 'Original'}</span>
|
||||
<p className="text-sm text-muted-foreground line-through opacity-60 mt-1">{aiModal.origText}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="notion-ai-result-section">
|
||||
{aiModal.type === 'preview' && <span className="notion-ai-result-label">{t('ai.result.suggestion') || 'Suggestion'}</span>}
|
||||
<div className="prose prose-sm max-w-none text-sm mt-1" dangerouslySetInnerHTML={{ __html: aiModal.html }} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-3 pt-3 border-t border-border/40">
|
||||
<button onClick={() => setAiModal(null)} className="notion-modal-btn">{t('common.cancel') || 'Annuler'}</button>
|
||||
{aiModal.type === 'preview' && (
|
||||
<button onClick={applyAiResult} className="notion-modal-btn notion-modal-btn-primary">{t('ai.result.apply') || 'Appliquer'}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user