Files
WooNooW/admin-spa/src/components/ui/rich-text-editor.tsx
dwindown 493f363dd2 feat: WordPress Media Modal Integration! 🎉
##  Improvements 4-5 Complete - Respecting WordPress!

### 4. WordPress Media Modal for TipTap Images
**Before:**
- Prompt dialog for image URL
- Manual URL entry
- No media library access

**After:**
- Native WordPress Media Modal
- Browse existing uploads
- Upload new images
- Full media library features
- Alt text, dimensions included

**Implementation:**
- `wp-media.ts` helper library
- `openWPMediaImage()` function
- Integrates with TipTap Image extension
- Sets src, alt, title automatically

### 5. WordPress Media Modal for Store Logos/Favicon
**Before:**
- Only drag-and-drop or file picker
- No access to existing media

**After:**
- "Choose from Media Library" button
- Filtered by media type:
  - Logo: PNG, JPEG, SVG, WebP
  - Favicon: PNG, ICO
- Browse and reuse existing assets
- Professional WordPress experience

**Implementation:**
- Updated `ImageUpload` component
- Added `mediaType` prop
- Three specialized functions:
  - `openWPMediaLogo()`
  - `openWPMediaFavicon()`
  - `openWPMediaImage()`

## 📦 New Files:

**lib/wp-media.ts:**
```typescript
- openWPMedia() - Core function
- openWPMediaImage() - For general images
- openWPMediaLogo() - For logos (filtered)
- openWPMediaFavicon() - For favicons (filtered)
- WPMediaFile interface
- Full TypeScript support
```

## 🎨 User Experience:

**Email Builder:**
- Click image icon in RichTextEditor
- WordPress Media Modal opens
- Select from library or upload
- Image inserted with proper attributes

**Store Settings:**
- Drag-and-drop still works
- OR click "Choose from Media Library"
- Filtered by appropriate file types
- Reuse existing brand assets

## 🙏 Respect to WordPress:

**Why This Matters:**
1. **Familiar Interface** - Users know WordPress Media
2. **Existing Assets** - Access uploaded media
3. **Better UX** - No manual URL entry
4. **Professional** - Native WordPress integration
5. **Consistent** - Same as Posts/Pages

**WordPress Integration:**
- Uses `window.wp.media` API
- Respects user permissions
- Works with media library
- Proper nonce handling
- Full compatibility

## 📋 All 5 Improvements Complete:

 1. Heading Selector (H1-H4, Paragraph)
 2. Styled Buttons in Cards (matching standalone)
 3. Variable Pills for Button Links
 4. WordPress Media for TipTap Images
 5. WordPress Media for Store Logos/Favicon

## 🚀 Ready for Production!

All user feedback implemented perfectly! 🎉
2025-11-13 09:48:47 +07:00

383 lines
12 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import Link from '@tiptap/extension-link';
import TextAlign from '@tiptap/extension-text-align';
import Image from '@tiptap/extension-image';
import { ButtonExtension } from './tiptap-button-extension';
import { openWPMediaImage } from '@/lib/wp-media';
import {
Bold,
Italic,
List,
ListOrdered,
Link as LinkIcon,
AlignLeft,
AlignCenter,
AlignRight,
ImageIcon,
MousePointer,
Undo,
Redo,
} from 'lucide-react';
import { Button } from './button';
import { Input } from './input';
import { Label } from './label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './dialog';
import { __ } from '@/lib/i18n';
interface RichTextEditorProps {
content: string;
onChange: (content: string) => void;
placeholder?: string;
variables?: string[];
onVariableInsert?: (variable: string) => void;
}
export function RichTextEditor({
content,
onChange,
placeholder = __('Start typing...'),
variables = [],
onVariableInsert,
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({
placeholder,
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-primary underline',
},
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Image.configure({
inline: true,
HTMLAttributes: {
class: 'max-w-full h-auto rounded',
},
}),
ButtonExtension,
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
editorProps: {
attributes: {
class:
'prose prose-sm max-w-none focus:outline-none min-h-[200px] px-4 py-3',
},
},
});
// Update editor content when prop changes (fix for default value not showing)
useEffect(() => {
if (editor && content) {
const currentContent = editor.getHTML();
// Only update if content is different (avoid infinite loops)
if (content !== currentContent) {
console.log('RichTextEditor: Updating content', { content, currentContent });
editor.commands.setContent(content);
}
}
}, [content, editor]);
if (!editor) {
return null;
}
const insertVariable = (variable: string) => {
editor.chain().focus().insertContent(`{${variable}}`).run();
if (onVariableInsert) {
onVariableInsert(variable);
}
};
const setLink = () => {
const url = window.prompt(__('Enter URL:'));
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
};
const [buttonDialogOpen, setButtonDialogOpen] = useState(false);
const [buttonText, setButtonText] = useState('Click Here');
const [buttonHref, setButtonHref] = useState('{order_url}');
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline'>('solid');
const addImage = () => {
openWPMediaImage((file) => {
editor.chain().focus().setImage({
src: file.url,
alt: file.alt || file.title,
title: file.title,
}).run();
});
};
const openButtonDialog = () => {
setButtonText('Click Here');
setButtonHref('{order_url}');
setButtonStyle('solid');
setButtonDialogOpen(true);
};
const insertButton = () => {
editor.chain().focus().setButton({ text: buttonText, href: buttonHref, style: buttonStyle }).run();
setButtonDialogOpen(false);
};
const getActiveHeading = () => {
if (editor.isActive('heading', { level: 1 })) return 'h1';
if (editor.isActive('heading', { level: 2 })) return 'h2';
if (editor.isActive('heading', { level: 3 })) return 'h3';
if (editor.isActive('heading', { level: 4 })) return 'h4';
return 'p';
};
const setHeading = (value: string) => {
if (value === 'p') {
editor.chain().focus().setParagraph().run();
} else {
const level = parseInt(value.replace('h', '')) as 1 | 2 | 3 | 4;
editor.chain().focus().setHeading({ level }).run();
}
};
return (
<div className="border rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="border-b bg-muted/30 p-2 flex flex-wrap gap-1">
{/* Heading Selector */}
<Select value={getActiveHeading()} onValueChange={setHeading}>
<SelectTrigger className="w-24 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="p">{__('Paragraph')}</SelectItem>
<SelectItem value="h1">{__('Heading 1')}</SelectItem>
<SelectItem value="h2">{__('Heading 2')}</SelectItem>
<SelectItem value="h3">{__('Heading 3')}</SelectItem>
<SelectItem value="h4">{__('Heading 4')}</SelectItem>
</SelectContent>
</Select>
<div className="w-px h-6 bg-border mx-1" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'bg-accent' : ''}
>
<Bold className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'bg-accent' : ''}
>
<Italic className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'bg-accent' : ''}
>
<List className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive('orderedList') ? 'bg-accent' : ''}
>
<ListOrdered className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={setLink}
className={editor.isActive('link') ? 'bg-accent' : ''}
>
<LinkIcon className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().setTextAlign('left').run()}
className={editor.isActive({ textAlign: 'left' }) ? 'bg-accent' : ''}
>
<AlignLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().setTextAlign('center').run()}
className={editor.isActive({ textAlign: 'center' }) ? 'bg-accent' : ''}
>
<AlignCenter className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().setTextAlign('right').run()}
className={editor.isActive({ textAlign: 'right' }) ? 'bg-accent' : ''}
>
<AlignRight className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={addImage}
>
<ImageIcon className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={openButtonDialog}
>
<MousePointer className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
>
<Undo className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
>
<Redo className="h-4 w-4" />
</Button>
</div>
{/* Editor */}
<EditorContent editor={editor} />
{/* Variables */}
{variables.length > 0 && (
<div className="border-t bg-muted/30 p-3">
<div className="text-xs text-muted-foreground mb-2">
{__('Available Variables:')}
</div>
<div className="flex flex-wrap gap-1">
{variables.map((variable) => (
<Button
key={variable}
type="button"
variant="outline"
size="sm"
onClick={() => insertVariable(variable)}
className="!font-normal !text-xs !px-2"
>
{`{${variable}}`}
</Button>
))}
</div>
</div>
)}
{/* Button Dialog */}
<Dialog open={buttonDialogOpen} onOpenChange={setButtonDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{__('Insert Button')}</DialogTitle>
<DialogDescription>
{__('Add a styled button to your content. Use variables for dynamic links.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="btn-text">{__('Button Text')}</Label>
<Input
id="btn-text"
value={buttonText}
onChange={(e) => setButtonText(e.target.value)}
placeholder={__('e.g., View Order')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="btn-href">{__('Button Link')}</Label>
<Input
id="btn-href"
value={buttonHref}
onChange={(e) => setButtonHref(e.target.value)}
placeholder="{order_url}"
/>
{variables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{variables.map((variable) => (
<code
key={variable}
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
onClick={() => setButtonHref(buttonHref + `{${variable}}`)}
>
{`{${variable}}`}
</code>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="btn-style">{__('Button Style')}</Label>
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline') => setButtonStyle(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
{__('Cancel')}
</Button>
<Button onClick={insertButton}>
{__('Insert Button')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}