md slash commands, text/img gen
This commit is contained in:
parent
f6dc781495
commit
b40a530413
175
packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx
Normal file
175
packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { COMMAND_PRIORITY_NORMAL, KEY_DOWN_COMMAND, $getSelection, $isRangeSelection, $createParagraphNode, $insertNodes, $getRoot, $createTextNode } from 'lexical';
|
||||
import { mergeRegister } from '@lexical/utils';
|
||||
import { RealmPlugin, addComposerChild$, usePublisher, insertMarkdown$ } from '@mdxeditor/editor';
|
||||
import { AIPromptPopup } from './AIPromptPopup';
|
||||
import { generateText } from '@/lib/openai';
|
||||
import { toast } from 'sonner';
|
||||
import { getUserSecrets } from '@/components/ImageWizard/db';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
// Hook to access secrets helper
|
||||
const useProviderApiKey = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
const getApiKey = useCallback(async (provider: string): Promise<string | null> => {
|
||||
if (!user) return null;
|
||||
|
||||
if (provider === 'openai') {
|
||||
try {
|
||||
const secrets = await getUserSecrets(user.id);
|
||||
if (secrets && secrets.openai_api_key) {
|
||||
return secrets.openai_api_key;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user secrets:', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: userProvider, error } = await supabase
|
||||
.from('provider_configs')
|
||||
.select('settings')
|
||||
.eq('user_id', user.id)
|
||||
.eq('name', provider)
|
||||
.eq('is_active', true)
|
||||
.single();
|
||||
|
||||
if (error || !userProvider) return null;
|
||||
const settings = userProvider.settings as any;
|
||||
return settings?.apiKey || null;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching API key for ${provider}:`, error);
|
||||
return null;
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return getApiKey;
|
||||
}
|
||||
|
||||
const AIGenerationComponent = () => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||
const getApiKey = useProviderApiKey();
|
||||
const [contextData, setContextData] = useState<{ selection: string, content: string }>({ selection: '', content: '' });
|
||||
const insertMarkdown = usePublisher(insertMarkdown$);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
KEY_DOWN_COMMAND,
|
||||
(event: KeyboardEvent) => {
|
||||
// Check for Ctrl+Space
|
||||
if (event.ctrlKey && event.code === 'Space') {
|
||||
event.preventDefault();
|
||||
|
||||
// Capture context before opening
|
||||
editor.getEditorState().read(() => {
|
||||
const selection = $getSelection();
|
||||
const selectionText = selection ? selection.getTextContent() : '';
|
||||
const root = $getRoot();
|
||||
const contentText = root ? root.getTextContent() : '';
|
||||
|
||||
setContextData({
|
||||
selection: selectionText,
|
||||
content: contentText
|
||||
});
|
||||
});
|
||||
|
||||
setIsPopupOpen(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_NORMAL
|
||||
)
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
const handleGenerate = async (prompt: string, provider: string, model: string, contextMode: 'selection' | 'content' | 'none', applicationMode: 'replace' | 'insert' | 'append') => {
|
||||
try {
|
||||
const apiKey = await getApiKey(provider);
|
||||
|
||||
// Construct prompt with context
|
||||
let finalPrompt = prompt;
|
||||
const instructions = `\n\nINSTRUCTIONS: Return the content formatted as Markdown. You can use code blocks, lists, and other markdown features. Do NOT include preamble or explanations. Just return the content directly.`;
|
||||
|
||||
if (contextMode === 'selection' && contextData.selection) {
|
||||
finalPrompt = `CONTEXT:\n\`\`\`\n${contextData.selection}\n\`\`\`\n\nREQUEST: ${prompt}${instructions}`;
|
||||
} else if (contextMode === 'content' && contextData.content) {
|
||||
finalPrompt = `CONTEXT:\n\`\`\`\n${contextData.content}\n\`\`\`\n\nREQUEST: ${prompt}${instructions}`;
|
||||
} else {
|
||||
finalPrompt = `${prompt}${instructions}`;
|
||||
}
|
||||
|
||||
// Generate text
|
||||
// Note: We're not using tools or streaming here yet for simplicity, just text generation
|
||||
const result = await generateText(finalPrompt, model, apiKey || undefined);
|
||||
|
||||
if (result) {
|
||||
// Clean up markdown wrapper
|
||||
let cleanedText = result.trim();
|
||||
|
||||
// Smart cleanup:
|
||||
// If the entire text is wrapped in ```markdown or ```text or just ``` (no lang), it's likely a metadata wrapper.
|
||||
// We want to strip these BUT preserve inner code blocks or code blocks with specific languages (e.g. ```javascript).
|
||||
|
||||
const wrapperMatch = cleanedText.match(/^```(markdown|text|txt)?\s*\n/i);
|
||||
if (wrapperMatch && cleanedText.endsWith('```')) {
|
||||
// It is wrapped. Strip the wrapper.
|
||||
cleanedText = cleanedText
|
||||
.substring(wrapperMatch[0].length, cleanedText.length - 3)
|
||||
.trim();
|
||||
}
|
||||
|
||||
|
||||
cleanedText = cleanedText.trim();
|
||||
|
||||
// Handle 'append' mode by moving selection to end first
|
||||
if (applicationMode === 'append') {
|
||||
// We need to move selection to end.
|
||||
// Since insertMarkdown uses the current selection, updating it via editor.update shoud work.
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
root.selectEnd();
|
||||
});
|
||||
}
|
||||
// For 'replace' or 'insert', the current selection is used automatically by insertMarkdown.
|
||||
|
||||
// Insert the markdown
|
||||
// Note: insertMarkdown$ is a signal that triggers the editor to insert markdown at the current selection.
|
||||
insertMarkdown(cleanedText);
|
||||
|
||||
toast.success('Generated text inserted');
|
||||
} else {
|
||||
toast.error('No text generated');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Generation error:', error);
|
||||
toast.error(`Error: ${error.message}`);
|
||||
throw error; // Re-throw to keep popup open or handle error in popup
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AIPromptPopup
|
||||
isOpen={isPopupOpen}
|
||||
onClose={() => setIsPopupOpen(false)}
|
||||
onGenerate={handleGenerate}
|
||||
hasSelection={!!contextData.selection}
|
||||
hasContent={!!contextData.content}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const aiGenerationPlugin = (): RealmPlugin => {
|
||||
return {
|
||||
init: (realm) => {
|
||||
realm.pubIn({
|
||||
[addComposerChild$]: () => <AIGenerationComponent />
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { $createParagraphNode, $getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, createCommand, LexicalCommand, $insertNodes } from 'lexical';
|
||||
import { RealmPlugin, addComposerChild$, $createImageNode } from '@mdxeditor/editor';
|
||||
import { AIImagePromptPopup } from './AIImagePromptPopup';
|
||||
import { createImage } from '@/lib/image-router';
|
||||
import { uploadFileToStorage } from '@/lib/db';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const OPEN_IMAGE_GEN_COMMAND: LexicalCommand<void> = createCommand('OPEN_IMAGE_GEN_COMMAND');
|
||||
|
||||
const AIImagePromptPopupWrapper = () => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const [hasContent, setHasContent] = useState(false);
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
OPEN_IMAGE_GEN_COMMAND,
|
||||
() => {
|
||||
// Check selection/content status when opening
|
||||
editor.getEditorState().read(() => {
|
||||
const selection = $getSelection();
|
||||
const textContent = editor.getEditorState().read(() => $getRoot().getTextContent());
|
||||
setHasSelection($isRangeSelection(selection) && !selection.isCollapsed());
|
||||
setHasContent(textContent.trim().length > 0);
|
||||
});
|
||||
setIsOpen(true);
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
const handleGenerate = async (prompt: string, model: string, aspectRatio: string, contextMode: 'selection' | 'content' | 'none') => {
|
||||
if (!user?.id) {
|
||||
toast.error("You must be logged in to generate images.");
|
||||
return;
|
||||
}
|
||||
|
||||
let fullPrompt = prompt;
|
||||
// Augment prompt with context if requested
|
||||
if (contextMode !== 'none') {
|
||||
const contextText = await new Promise<string>((resolve) => {
|
||||
editor.getEditorState().read(() => {
|
||||
let text = '';
|
||||
if (contextMode === 'selection') {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
text = selection.getTextContent();
|
||||
}
|
||||
} else if (contextMode === 'content') {
|
||||
text = $getRoot().getTextContent();
|
||||
}
|
||||
resolve(text);
|
||||
});
|
||||
});
|
||||
|
||||
if (contextText) {
|
||||
// Truncate context if too long (arbitrary limit for prompt safety)
|
||||
const safeContext = contextText.slice(0, 1000);
|
||||
fullPrompt = `${prompt}\n\nContext: ${safeContext}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createImage(
|
||||
fullPrompt,
|
||||
model,
|
||||
undefined, // apiKey handled internally/server-side usually or locally
|
||||
aspectRatio
|
||||
);
|
||||
|
||||
if (!result || !result.imageData) {
|
||||
toast.error("Failed to generate image.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert ArrayBuffer to Blob
|
||||
const blob = new Blob([result.imageData], { type: 'image/png' });
|
||||
|
||||
// Upload to Supabase
|
||||
const fileName = `${user.id}/gen-${Date.now()}.png`;
|
||||
const publicUrl = await uploadFileToStorage(user.id, blob, fileName);
|
||||
|
||||
if (publicUrl) {
|
||||
editor.update(() => {
|
||||
const imageNode = $createImageNode({
|
||||
src: publicUrl,
|
||||
altText: prompt,
|
||||
title: prompt
|
||||
});
|
||||
// Insert at current selection
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.insertNodes([imageNode]);
|
||||
} else {
|
||||
// Fallback append
|
||||
// Use $insertNodes which handles selection fallback usually, but if no selection...
|
||||
$insertNodes([imageNode]);
|
||||
}
|
||||
});
|
||||
toast.success("Image generated and inserted!");
|
||||
} else {
|
||||
toast.error("Failed to upload generated image.");
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Image generation error:", error);
|
||||
toast.error(error.message || "Error generating image");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AIImagePromptPopup
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
onGenerate={handleGenerate}
|
||||
hasSelection={hasSelection}
|
||||
hasContent={hasContent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const aiImageGenerationPlugin = (): RealmPlugin => {
|
||||
return {
|
||||
init: (realm) => {
|
||||
realm.pubIn({
|
||||
[addComposerChild$]: () => <AIImagePromptPopupWrapper />
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Helper to access root node (Lexical hack if import missing)
|
||||
import { $getRoot } from 'lexical';
|
||||
243
packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx
Normal file
243
packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2, Sparkles, X, Image as ImageIcon } from 'lucide-react';
|
||||
import { T, translate } from '@/i18n';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ModelSelector } from '@/components/ImageWizard/components/ModelSelector';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface AIImagePromptPopupProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onGenerate: (prompt: string, model: string, aspectRatio: string, contextMode: 'selection' | 'content' | 'none') => Promise<void>;
|
||||
initialModel?: string;
|
||||
hasSelection: boolean;
|
||||
hasContent: boolean;
|
||||
}
|
||||
|
||||
export const AIImagePromptPopup: React.FC<AIImagePromptPopupProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onGenerate,
|
||||
initialModel = 'google/gemini-3-pro-image-preview',
|
||||
hasSelection,
|
||||
hasContent
|
||||
}) => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
|
||||
// Initialize model from local storage or props
|
||||
const [model, setModel] = useState(() => {
|
||||
return localStorage.getItem('ai_image_last_model') || initialModel;
|
||||
});
|
||||
|
||||
const [aspectRatio, setAspectRatio] = useState(() => {
|
||||
return localStorage.getItem('ai_image_last_aspect_ratio') || '1:1';
|
||||
});
|
||||
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [contextMode, setContextMode] = useState<'selection' | 'content' | 'none'>('none');
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Save model/aspect ratio changes
|
||||
useEffect(() => {
|
||||
if (model) localStorage.setItem('ai_image_last_model', model);
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
if (aspectRatio) localStorage.setItem('ai_image_last_aspect_ratio', aspectRatio);
|
||||
}, [aspectRatio]);
|
||||
|
||||
// Initial context mode selection
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (hasSelection) {
|
||||
setContextMode('selection');
|
||||
} else if (hasContent) {
|
||||
setContextMode('content');
|
||||
} else {
|
||||
setContextMode('none');
|
||||
}
|
||||
}
|
||||
}, [isOpen, hasSelection, hasContent]);
|
||||
|
||||
// Auto-focus textarea when opened
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle ESC key to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
if (e.key === 'Escape') {
|
||||
if (!isGenerating) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose, isGenerating]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
await onGenerate(prompt, model, aspectRatio, contextMode);
|
||||
onClose();
|
||||
setPrompt('');
|
||||
} catch (error) {
|
||||
console.error("Image generation failed", error);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleGenerate();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<Card className="w-[500px] max-w-[90vw] p-0 overflow-hidden shadow-2xl glass-card border-slate-200/50 dark:border-slate-700/50 bg-white/90 dark:bg-slate-900/90 ring-1 ring-slate-900/5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border/50 bg-muted/30">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground/80">
|
||||
<ImageIcon className="w-4 h-4 text-purple-500" />
|
||||
<T>Generate Image</T>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-full hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Prompt Input */}
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={translate("Describe the image you want to generate...")}
|
||||
className="min-h-[100px] resize-none bg-background/50 focus:bg-background transition-colors text-base"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Context Toggles */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={contextMode === 'selection' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="text-xs h-7"
|
||||
onClick={() => setContextMode('selection')}
|
||||
disabled={!hasSelection || isGenerating}
|
||||
title="Use selected text as context"
|
||||
>
|
||||
Selection {hasSelection && '✓'}
|
||||
</Button>
|
||||
<Button
|
||||
variant={contextMode === 'content' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="text-xs h-7"
|
||||
onClick={() => setContextMode('content')}
|
||||
disabled={!hasContent || isGenerating}
|
||||
title="Use entire document as context"
|
||||
>
|
||||
Content {hasContent && '✓'}
|
||||
</Button>
|
||||
<Button
|
||||
variant={contextMode === 'none' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="text-xs h-7"
|
||||
onClick={() => setContextMode('none')}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
No Context
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Controls Row: Aspect Ratio & Model */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Aspect Ratio</Label>
|
||||
<Select value={aspectRatio} onValueChange={setAspectRatio} disabled={isGenerating}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select ratio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1:1">Square (1:1)</SelectItem>
|
||||
<SelectItem value="16:9">Landscape (16:9)</SelectItem>
|
||||
<SelectItem value="4:3">Standard (4:3)</SelectItem>
|
||||
<SelectItem value="3:4">Portrait (3:4)</SelectItem>
|
||||
<SelectItem value="9:16">Mobile (9:16)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ModelSelector
|
||||
selectedModel={model}
|
||||
onChange={setModel}
|
||||
showStepNumber={false}
|
||||
label="Model"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={!prompt.trim() || isGenerating}
|
||||
className={`w-full ${isGenerating ? 'opacity-80' : ''} bg-purple-600 hover:bg-purple-700 text-white`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<T>Generating...</T>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
<T>Generate Image</T>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer / Status */}
|
||||
{isGenerating && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="h-1 w-full bg-muted overflow-hidden rounded-full">
|
||||
<div className="h-full bg-purple-500 animate-progress-indeterminateOrigin" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
297
packages/ui/src/components/lazy-editors/AIPromptPopup.tsx
Normal file
297
packages/ui/src/components/lazy-editors/AIPromptPopup.tsx
Normal file
@ -0,0 +1,297 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ProviderSelector } from '@/components/filters/ProviderSelector';
|
||||
import { Loader2, Sparkles, X } from 'lucide-react';
|
||||
import { T, translate } from '@/i18n';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface AIPromptPopupProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onGenerate: (prompt: string, provider: string, model: string, contextMode: 'selection' | 'content' | 'none', applicationMode: 'replace' | 'insert' | 'append') => Promise<void>;
|
||||
initialProvider?: string;
|
||||
initialModel?: string;
|
||||
hasSelection: boolean;
|
||||
hasContent: boolean;
|
||||
targetNode?: HTMLElement | null;
|
||||
}
|
||||
|
||||
export const AIPromptPopup: React.FC<AIPromptPopupProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onGenerate,
|
||||
initialProvider = 'openai',
|
||||
initialModel = 'gpt-4o',
|
||||
hasSelection,
|
||||
hasContent
|
||||
}) => {
|
||||
// State with partial persistence
|
||||
const [prompt, setPrompt] = useState('');
|
||||
|
||||
// Initialize provider/model from local storage or props
|
||||
const [provider, setProvider] = useState(() => {
|
||||
return localStorage.getItem('ai_last_provider') || initialProvider;
|
||||
});
|
||||
const [model, setModel] = useState(() => {
|
||||
return localStorage.getItem('ai_last_model') || initialModel;
|
||||
});
|
||||
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [contextMode, setContextMode] = useState<'selection' | 'content' | 'none'>('none');
|
||||
const [applicationMode, setApplicationMode] = useState<'replace' | 'insert' | 'append'>('append');
|
||||
|
||||
// History state
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Save provider/model changes
|
||||
useEffect(() => {
|
||||
if (provider) localStorage.setItem('ai_last_provider', provider);
|
||||
}, [provider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (model) localStorage.setItem('ai_last_model', model);
|
||||
}, [model]);
|
||||
|
||||
// Initial context mode selection
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (hasSelection) {
|
||||
setContextMode('selection');
|
||||
setApplicationMode('replace');
|
||||
} else if (hasContent) {
|
||||
setContextMode('content');
|
||||
setApplicationMode('append');
|
||||
} else {
|
||||
setContextMode('none');
|
||||
setApplicationMode('insert');
|
||||
}
|
||||
|
||||
// Load history from local storage
|
||||
const savedHistory = localStorage.getItem('ai_prompt_history');
|
||||
if (savedHistory) {
|
||||
try {
|
||||
setHistory(JSON.parse(savedHistory));
|
||||
} catch (e) { console.error('Failed to parse history', e); }
|
||||
}
|
||||
}
|
||||
}, [isOpen, hasSelection, hasContent]);
|
||||
|
||||
// Auto-focus textarea when opened
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle ESC key to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
if (e.key === 'Escape') {
|
||||
if (!isGenerating) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose, isGenerating]);
|
||||
|
||||
const addToHistory = (text: string) => {
|
||||
const newHistory = [text, ...history.filter(h => h !== text)].slice(0, 50);
|
||||
setHistory(newHistory);
|
||||
localStorage.setItem('ai_prompt_history', JSON.stringify(newHistory));
|
||||
setHistoryIndex(-1);
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
addToHistory(prompt);
|
||||
try {
|
||||
await onGenerate(prompt, provider, model, contextMode, applicationMode);
|
||||
onClose();
|
||||
setPrompt('');
|
||||
} catch (error) {
|
||||
console.error("Generation failed", error);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleGenerate();
|
||||
} else if (e.key === 'ArrowUp' && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
|
||||
if (nextIndex !== historyIndex && history[nextIndex]) {
|
||||
setHistoryIndex(nextIndex);
|
||||
setPrompt(history[nextIndex]);
|
||||
}
|
||||
} else if (e.key === 'ArrowDown' && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
const prevIndex = Math.max(historyIndex - 1, -1);
|
||||
if (prevIndex !== historyIndex) {
|
||||
setHistoryIndex(prevIndex);
|
||||
setPrompt(prevIndex === -1 ? '' : history[prevIndex]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<Card className="w-[500px] max-w-[90vw] p-0 overflow-hidden shadow-2xl glass-card border-slate-200/50 dark:border-slate-700/50 bg-white/90 dark:bg-slate-900/90 ring-1 ring-slate-900/5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border/50 bg-muted/30">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground/80">
|
||||
<Sparkles className="w-4 h-4 text-primary" />
|
||||
<T>AI Assistant</T>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-full hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Prompt Input */}
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={translate("Ask AI to write something... (Enter to generate)")}
|
||||
className="min-h-[100px] resize-none bg-background/50 focus:bg-background transition-colors text-base"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
{history.length > 0 && (
|
||||
<div className="absolute top-2 right-2 text-[10px] text-muted-foreground opacity-50 pointer-events-none">
|
||||
Ctrl+↑ for history
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context Toggles */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Context</Label>
|
||||
<RadioGroup
|
||||
value={contextMode}
|
||||
onValueChange={(val) => setContextMode(val as any)}
|
||||
className="flex flex-row gap-4"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="selection" id="c-selection" disabled={!hasSelection} />
|
||||
<Label htmlFor="c-selection" className={`text-sm font-normal cursor-pointer ${!hasSelection ? 'opacity-50' : ''}`}>
|
||||
Selection {hasSelection && '✓'}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="content" id="c-content" disabled={!hasContent} />
|
||||
<Label htmlFor="c-content" className={`text-sm font-normal cursor-pointer ${!hasContent ? 'opacity-50' : ''}`}>
|
||||
Content {hasContent && '✓'}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="none" id="c-none" />
|
||||
<Label htmlFor="c-none" className="text-sm font-normal cursor-pointer">No Context</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Controls Row: Mode Selector & Provider */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Insertion Mode */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Insertion Mode</Label>
|
||||
<RadioGroup
|
||||
value={applicationMode}
|
||||
onValueChange={(val) => setApplicationMode(val as any)}
|
||||
className="flex flex-row gap-4"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="replace" id="r-replace" />
|
||||
<Label htmlFor="r-replace" className="text-sm font-normal cursor-pointer">Replace</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="insert" id="r-insert" />
|
||||
<Label htmlFor="r-insert" className="text-sm font-normal cursor-pointer">Insert</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="append" id="r-append" />
|
||||
<Label htmlFor="r-append" className="text-sm font-normal cursor-pointer">Append</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Provider Selector & Generate */}
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<div className="flex-1">
|
||||
<ProviderSelector
|
||||
provider={provider}
|
||||
model={model}
|
||||
onProviderChange={setProvider}
|
||||
onModelChange={setModel}
|
||||
disabled={isGenerating}
|
||||
showManagement={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={!prompt.trim() || isGenerating}
|
||||
className={`w-full ${isGenerating ? 'opacity-80' : ''}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<T>Working...</T>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
<T>Generate</T>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer / Status */}
|
||||
{isGenerating && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="h-1 w-full bg-muted overflow-hidden rounded-full">
|
||||
<div className="h-full bg-primary animate-progress-indeterminateOrigin" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -13,6 +13,9 @@ import '@/styles/mdx-editor-theme.css';
|
||||
import { InsertCustomImage, MicrophoneTranscribe, FullscreenToggle, SaveButton } from '@/components/EditorActions';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import { useState } from 'react';
|
||||
import { slashCommandPlugin } from './SlashCommandPlugin';
|
||||
import { aiGenerationPlugin } from './AIGenerationPlugin';
|
||||
import { aiImageGenerationPlugin } from './AIImageGenerationPlugin';
|
||||
|
||||
export interface MDXEditorWithImagePickerProps {
|
||||
value: string;
|
||||
@ -127,6 +130,9 @@ export default function MDXEditorInternal({
|
||||
codeBlockPlugin({ defaultCodeBlockLanguage: 'js' }),
|
||||
codeMirrorPlugin({ codeBlockLanguages: { js: 'JavaScript', css: 'CSS', 'json': 'JSON', '': 'Text' } }),
|
||||
sandpackPlugin({ sandpackConfig: simpleSandpackConfig }),
|
||||
slashCommandPlugin({ onRequestImage }),
|
||||
aiGenerationPlugin(),
|
||||
aiImageGenerationPlugin(),
|
||||
toolbarPlugin({
|
||||
toolbarClassName: 'mdx-toolbar',
|
||||
toolbarContents: () => (
|
||||
|
||||
254
packages/ui/src/components/lazy-editors/SlashCommandPlugin.tsx
Normal file
254
packages/ui/src/components/lazy-editors/SlashCommandPlugin.tsx
Normal file
@ -0,0 +1,254 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { LexicalTypeaheadMenuPlugin, MenuOption, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin';
|
||||
import { TextNode, $createParagraphNode, $getSelection, $isRangeSelection, FORMAT_ELEMENT_COMMAND } from 'lexical';
|
||||
import { $createHeadingNode, $createQuoteNode } from '@lexical/rich-text';
|
||||
import { INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_CHECK_LIST_COMMAND } from '@lexical/list';
|
||||
import { INSERT_TABLE_COMMAND } from '@lexical/table';
|
||||
import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
CheckSquare,
|
||||
Table,
|
||||
Quote,
|
||||
Image,
|
||||
Code,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { OPEN_IMAGE_GEN_COMMAND } from './AIImageGenerationPlugin';
|
||||
|
||||
// Import from MDXEditor to hook into its plugin system and access ImageNode creation
|
||||
import { RealmPlugin, addComposerChild$, $createImageNode } from '@mdxeditor/editor';
|
||||
import { $insertNodes } from 'lexical';
|
||||
|
||||
// Define the option type for the menu
|
||||
class SlashCommandOption extends MenuOption {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
keywords: string[];
|
||||
onSelect: (editor: any) => void;
|
||||
|
||||
constructor(title: string, icon: React.ReactNode, keywords: string[], onSelect: (editor: any) => void) {
|
||||
super(title);
|
||||
this.title = title;
|
||||
this.icon = icon;
|
||||
this.keywords = keywords || [];
|
||||
this.onSelect = onSelect;
|
||||
}
|
||||
}
|
||||
|
||||
// The Menu Component
|
||||
function SlashCommandMenu({
|
||||
anchorElementRef,
|
||||
onRequestImage
|
||||
}: {
|
||||
anchorElementRef: React.MutableRefObject<HTMLElement | null>;
|
||||
onRequestImage?: () => Promise<string>;
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [queryString, setQueryString] = useState<string | null>(null);
|
||||
|
||||
const checkForSlashTrigger = useBasicTypeaheadTriggerMatch('/', { minLength: 0 });
|
||||
|
||||
const options = [
|
||||
new SlashCommandOption('Heading 1', <Heading1 size={16} />, ['h1', 'heading'], () => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createHeadingNode('h1'));
|
||||
}
|
||||
});
|
||||
}),
|
||||
new SlashCommandOption('Heading 2', <Heading2 size={16} />, ['h2', 'heading'], () => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createHeadingNode('h2'));
|
||||
}
|
||||
});
|
||||
}),
|
||||
new SlashCommandOption('Heading 3', <Heading3 size={16} />, ['h3', 'heading'], () => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createHeadingNode('h3'));
|
||||
}
|
||||
});
|
||||
}),
|
||||
new SlashCommandOption('Bullet List', <List size={16} />, ['ul', 'list', 'bullet'], () => {
|
||||
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
|
||||
}),
|
||||
new SlashCommandOption('Numbered List', <ListOrdered size={16} />, ['ol', 'list', 'number'], () => {
|
||||
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
|
||||
}),
|
||||
new SlashCommandOption('Check List', <CheckSquare size={16} />, ['todo', 'check', 'task'], () => {
|
||||
editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);
|
||||
}),
|
||||
new SlashCommandOption('Table', <Table size={16} />, ['table', 'grid'], () => {
|
||||
editor.dispatchCommand(INSERT_TABLE_COMMAND, { columns: '3', rows: '3', includeHeaders: false });
|
||||
}),
|
||||
new SlashCommandOption('Quote', <Quote size={16} />, ['quote', 'blockquote'], () => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createQuoteNode());
|
||||
}
|
||||
});
|
||||
}),
|
||||
new SlashCommandOption('Generate Image', <Sparkles size={16} />, ['ai', 'image', 'generate', 'gen'], () => {
|
||||
editor.dispatchCommand(OPEN_IMAGE_GEN_COMMAND, undefined);
|
||||
}),
|
||||
// Special handling for Image if handler provided
|
||||
...(onRequestImage ? [
|
||||
new SlashCommandOption('Image', <Image size={16} />, ['image', 'picture', 'photo'], () => {
|
||||
onRequestImage().then(url => {
|
||||
if (url) {
|
||||
editor.update(() => {
|
||||
const imageNode = $createImageNode({
|
||||
src: url,
|
||||
altText: 'image',
|
||||
title: 'image'
|
||||
});
|
||||
$insertNodes([imageNode]);
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
] : [])
|
||||
];
|
||||
|
||||
// Helper from Lexical to change block type
|
||||
const $setBlocksType = (selection: any, createElement: () => any) => {
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
const nodes = selection.getNodes();
|
||||
if (nodes.length === 0 && selection.isCollapsed()) {
|
||||
const p = anchor.getNode().getParent();
|
||||
if (p) {
|
||||
const element = createElement();
|
||||
p.replace(element);
|
||||
element.select();
|
||||
}
|
||||
} else {
|
||||
// Advanced block logic omitted for simplicity, stick to basic replacement
|
||||
// This often requires @lexical/selection imports which might be tricky with transitive deps
|
||||
// Let's stick to simplest implementation for H1-H3: replace parent if it's a paragraph
|
||||
const anchorNode = anchor.getNode();
|
||||
const element = anchorNode.getTopLevelElementOrThrow();
|
||||
const newElement = createElement();
|
||||
element.replace(newElement);
|
||||
newElement.select();
|
||||
}
|
||||
};
|
||||
|
||||
// We need to fetch the INSERT_IMAGE_COMMAND from somewhere or define it if we want to use it
|
||||
// MDXEditor likely exports it or we can import from lexical if it's standard
|
||||
// Actually MDXEditor has its own image handling.
|
||||
// Let's simplify and assume the user will define `insertImage` logic
|
||||
// or we need to import `insertImage$` from MDXEditor gurx store.
|
||||
|
||||
// Re-filtering options based on query
|
||||
const optionsFilter = useCallback((option: SlashCommandOption, text: string) => {
|
||||
if (!text) return true;
|
||||
const lowerText = text.toLowerCase();
|
||||
return (
|
||||
option.title.toLowerCase().includes(lowerText) ||
|
||||
option.keywords.some(k => k.includes(lowerText))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(
|
||||
selectedOption: SlashCommandOption,
|
||||
nodeToRemove: TextNode | null,
|
||||
closeMenu: () => void,
|
||||
) => {
|
||||
editor.update(() => {
|
||||
if (nodeToRemove) {
|
||||
nodeToRemove.remove();
|
||||
}
|
||||
selectedOption.onSelect(editor);
|
||||
closeMenu();
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
return (
|
||||
<LexicalTypeaheadMenuPlugin
|
||||
onQueryChange={setQueryString}
|
||||
onSelectOption={onSelectOption}
|
||||
triggerFn={checkForSlashTrigger}
|
||||
options={options}
|
||||
menuRenderFn={(
|
||||
anchorElementRef,
|
||||
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }
|
||||
) => {
|
||||
if (anchorElementRef.current == null || options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return anchorElementRef.current && ReactDOM.createPortal(
|
||||
<div className="z-50 min-w-[200px] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{options.map((option, i) => {
|
||||
// Filter manually if the plugin doesn't do it for us (it usually does but we want custom fuzzy logic maybe)
|
||||
// Actually LexicalTypeaheadMenuPlugin takes `options` and we filter inside `options` prop usually?
|
||||
// No, the plugin passes all options to renderFn usually?
|
||||
// Wait, LexicalTypeaheadMenuPlugin expects *filtered* options passed to it?
|
||||
// Let's double check docs mentally.
|
||||
// Usually we filter in parent and pass filtered to 'options'.
|
||||
// Let's filter here for safety.
|
||||
if (queryString && !optionsFilter(option, queryString)) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.title}
|
||||
className={`flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground ${selectedIndex === i ? 'bg-accent text-accent-foreground' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
setHighlightedIndex(i);
|
||||
selectOptionAndCleanUp(option);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHighlightedIndex(i);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{option.icon}
|
||||
<span>{option.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
anchorElementRef.current
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure the command is imported if we use it, otherwise define rudimentary one or omit image for now to avoid breaking
|
||||
// actually import { imagePlugin } from '@mdxeditor/editor' handles images,
|
||||
// usually it listens to standard markdown image syntax or specific commands.
|
||||
|
||||
// The Plugin Definition
|
||||
export const slashCommandPlugin = (params?: { onRequestImage?: () => Promise<string> }): RealmPlugin => {
|
||||
return {
|
||||
init: (realm) => {
|
||||
realm.pubIn({
|
||||
[addComposerChild$]: () => (
|
||||
<SlashCommandMenu
|
||||
anchorElementRef={{ current: document.body }} // Placeholder, TypeaheadPlugin uses its own anchoring logic usually or expects a ref
|
||||
onRequestImage={params?.onRequestImage}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,6 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import MarkdownEditor from '@/components/MarkdownEditorEx';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user