535 lines
19 KiB
TypeScript
535 lines
19 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, DialogBody } 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 3.10+ includes Link by default, disable since we configure separately
|
|
StarterKit.configure({ link: false }),
|
|
Placeholder.configure({
|
|
placeholder,
|
|
}),
|
|
// ButtonExtension MUST come before Link to ensure buttons are parsed first
|
|
ButtonExtension,
|
|
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',
|
|
},
|
|
}),
|
|
],
|
|
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 [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-4 [&_h1]:mb-2 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-3 [&_h2]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-2 [&_h3]:mb-1 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-2 [&_h4]:mb-1',
|
|
},
|
|
},
|
|
});
|
|
|
|
// 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) {
|
|
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' | 'link'>('solid');
|
|
const [isEditingButton, setIsEditingButton] = useState(false);
|
|
const [editingButtonPos, setEditingButtonPos] = useState<number | null>(null);
|
|
|
|
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');
|
|
setIsEditingButton(false);
|
|
setEditingButtonPos(null);
|
|
setButtonDialogOpen(true);
|
|
};
|
|
|
|
// Handle clicking on buttons in the editor to edit them
|
|
const handleEditorClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
const target = e.target as HTMLElement;
|
|
const buttonEl = target.closest('a[data-button]') as HTMLElement | null;
|
|
|
|
if (buttonEl && editor) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// Get button attributes
|
|
const text = buttonEl.getAttribute('data-text') || buttonEl.textContent?.replace('🔘 ', '') || 'Click Here';
|
|
const href = buttonEl.getAttribute('data-href') || '#';
|
|
const style = (buttonEl.getAttribute('data-style') as 'solid' | 'outline') || 'solid';
|
|
|
|
// Find the position of this button node
|
|
const { state } = editor.view;
|
|
let foundPos: number | null = null;
|
|
|
|
state.doc.descendants((node, pos) => {
|
|
if (node.type.name === 'button' &&
|
|
node.attrs.text === text &&
|
|
node.attrs.href === href) {
|
|
foundPos = pos;
|
|
return false; // Stop iteration
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Open dialog in edit mode
|
|
setButtonText(text);
|
|
setButtonHref(href);
|
|
setButtonStyle(style);
|
|
setIsEditingButton(true);
|
|
setEditingButtonPos(foundPos);
|
|
setButtonDialogOpen(true);
|
|
}
|
|
};
|
|
|
|
const insertButton = () => {
|
|
if (isEditingButton && editingButtonPos !== null && editor) {
|
|
// Delete old button and insert new one at same position
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
|
|
.insertContentAt(editingButtonPos, {
|
|
type: 'button',
|
|
attrs: { text: buttonText, href: buttonHref, style: buttonStyle },
|
|
})
|
|
.run();
|
|
} else {
|
|
// Insert new button
|
|
editor.chain().focus().setButton({ text: buttonText, href: buttonHref, style: buttonStyle }).run();
|
|
}
|
|
setButtonDialogOpen(false);
|
|
setIsEditingButton(false);
|
|
setEditingButtonPos(null);
|
|
};
|
|
|
|
const deleteButton = () => {
|
|
if (editingButtonPos !== null && editor) {
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
|
|
.run();
|
|
setButtonDialogOpen(false);
|
|
setIsEditingButton(false);
|
|
setEditingButtonPos(null);
|
|
}
|
|
};
|
|
|
|
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 */}
|
|
<div onClick={handleEditorClick}>
|
|
<EditorContent editor={editor} />
|
|
</div>
|
|
|
|
{/* Variables - Collapsible and Categorized */}
|
|
{variables.length > 0 && (
|
|
<details className="border-t bg-muted/30">
|
|
<summary className="p-3 text-xs text-muted-foreground cursor-pointer hover:bg-muted/50 flex items-center gap-2 select-none">
|
|
<span className="text-[10px]">▶</span>
|
|
{__('Insert Variable')}
|
|
<span className="text-[10px] opacity-60">({variables.length})</span>
|
|
</summary>
|
|
<div className="p-3 pt-0 space-y-3">
|
|
{/* Order Variables */}
|
|
{variables.some(v => v.startsWith('order')) && (
|
|
<div>
|
|
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Order')}</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{variables.filter(v => v.startsWith('order')).map((variable) => (
|
|
<button
|
|
key={variable}
|
|
type="button"
|
|
onClick={() => insertVariable(variable)}
|
|
className="text-[11px] px-1.5 py-0.5 bg-blue-50 text-blue-700 rounded hover:bg-blue-100 transition-colors"
|
|
>
|
|
{`{${variable}}`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Subscriber/Customer Variables */}
|
|
{variables.some(v => v.startsWith('customer') || v.startsWith('subscriber') || (v.includes('_name') && !v.startsWith('order') && !v.startsWith('site') && !v.startsWith('store'))) && (
|
|
<div>
|
|
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Subscriber')}</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{variables.filter(v => v.startsWith('customer') || v.startsWith('subscriber') || (v.includes('address') && !v.startsWith('shipping'))).map((variable) => (
|
|
<button
|
|
key={variable}
|
|
type="button"
|
|
onClick={() => insertVariable(variable)}
|
|
className="text-[11px] px-1.5 py-0.5 bg-green-50 text-green-700 rounded hover:bg-green-100 transition-colors"
|
|
>
|
|
{`{${variable}}`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Shipping/Payment Variables */}
|
|
{variables.some(v => v.startsWith('shipping') || v.startsWith('payment') || v.startsWith('tracking')) && (
|
|
<div>
|
|
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Shipping & Payment')}</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{variables.filter(v => v.startsWith('shipping') || v.startsWith('payment') || v.startsWith('tracking')).map((variable) => (
|
|
<button
|
|
key={variable}
|
|
type="button"
|
|
onClick={() => insertVariable(variable)}
|
|
className="text-[11px] px-1.5 py-0.5 bg-orange-50 text-orange-700 rounded hover:bg-orange-100 transition-colors"
|
|
>
|
|
{`{${variable}}`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Store/Site Variables */}
|
|
{variables.some(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.startsWith('current') || v.includes('_url') || v.startsWith('support') || v.startsWith('review')) && (
|
|
<div>
|
|
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Store & Links')}</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{variables.filter(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.startsWith('current') || v.startsWith('my_account') || v.startsWith('support') || v.startsWith('review') || (v.includes('_url') && !v.startsWith('order') && !v.startsWith('tracking') && !v.startsWith('payment'))).map((variable) => (
|
|
<button
|
|
key={variable}
|
|
type="button"
|
|
onClick={() => insertVariable(variable)}
|
|
className="text-[11px] px-1.5 py-0.5 bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
|
|
>
|
|
{`{${variable}}`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</details>
|
|
)}
|
|
|
|
{/* Button Dialog */}
|
|
<Dialog open={buttonDialogOpen} onOpenChange={(open) => {
|
|
setButtonDialogOpen(open);
|
|
if (!open) {
|
|
setIsEditingButton(false);
|
|
setEditingButtonPos(null);
|
|
}
|
|
}}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{isEditingButton ? __('Edit Button') : __('Insert Button')}</DialogTitle>
|
|
<DialogDescription>
|
|
{isEditingButton
|
|
? __('Edit the button properties below. Click on the button to save.')
|
|
: __('Add a styled button to your content. Use variables for dynamic links.')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<DialogBody>
|
|
<div className="space-y-4 !p-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.filter(v => v.includes('_url') || v.includes('_link')).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' | 'link') => setButtonStyle(value)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
|
|
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
|
|
<SelectItem value="link">{__('Plain Link')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</DialogBody>
|
|
|
|
<DialogFooter className="flex-col sm:flex-row gap-2">
|
|
{isEditingButton && (
|
|
<Button variant="destructive" onClick={deleteButton} className="sm:mr-auto">
|
|
{__('Delete')}
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
|
|
{__('Cancel')}
|
|
</Button>
|
|
<Button onClick={insertButton}>
|
|
{isEditingButton ? __('Update Button') : __('Insert Button')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|