## ✅ 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! 🎉
383 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|