feat: simplify TipTap button styling + add click-to-edit

Button Styling:
- Buttons now render as simple links with 🔘 prefix in editor
- No more styled button appearance in TipTap (was inconsistent)
- Actual button styling still happens in email (EmailRenderer.php)

Click-to-Edit:
- Click any button in the editor to open edit dialog
- Edit button text, link URL, and style (solid/outline)
- Delete button option in edit mode
- Updates button in-place instead of requiring recreation

Dialog improvements:
- Shows 'Edit Button' title in edit mode
- Shows 'Update Button' vs 'Insert Button' based on mode
- Delete button (red) appears only in edit mode
This commit is contained in:
Dwindi Ramadhana
2026-01-03 17:22:34 +07:00
parent a98217897c
commit b010a88619
2 changed files with 100 additions and 44 deletions

View File

@@ -76,14 +76,6 @@ export function RichTextEditor({
class: 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', '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',
}, },
handleClick: (view, pos, event) => {
const target = event.target as HTMLElement;
if (target.tagName === 'A' || target.closest('a')) {
event.preventDefault();
return true;
}
return false;
},
}, },
}); });
@@ -121,6 +113,8 @@ export function RichTextEditor({
const [buttonText, setButtonText] = useState('Click Here'); const [buttonText, setButtonText] = useState('Click Here');
const [buttonHref, setButtonHref] = useState('{order_url}'); const [buttonHref, setButtonHref] = useState('{order_url}');
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline'>('solid'); const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline'>('solid');
const [isEditingButton, setIsEditingButton] = useState(false);
const [editingButtonPos, setEditingButtonPos] = useState<number | null>(null);
const addImage = () => { const addImage = () => {
openWPMediaImage((file) => { openWPMediaImage((file) => {
@@ -136,12 +130,81 @@ export function RichTextEditor({
setButtonText('Click Here'); setButtonText('Click Here');
setButtonHref('{order_url}'); setButtonHref('{order_url}');
setButtonStyle('solid'); setButtonStyle('solid');
setIsEditingButton(false);
setEditingButtonPos(null);
setButtonDialogOpen(true); 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 = () => { 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(); editor.chain().focus().setButton({ text: buttonText, href: buttonHref, style: buttonStyle }).run();
}
setButtonDialogOpen(false); 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 = () => { const getActiveHeading = () => {
@@ -293,7 +356,9 @@ export function RichTextEditor({
</div> </div>
{/* Editor */} {/* Editor */}
<div onClick={handleEditorClick}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
</div>
{/* Variables - Collapsible and Categorized */} {/* Variables - Collapsible and Categorized */}
{variables.length > 0 && ( {variables.length > 0 && (
@@ -381,12 +446,20 @@ export function RichTextEditor({
)} )}
{/* Button Dialog */} {/* Button Dialog */}
<Dialog open={buttonDialogOpen} onOpenChange={setButtonDialogOpen}> <Dialog open={buttonDialogOpen} onOpenChange={(open) => {
setButtonDialogOpen(open);
if (!open) {
setIsEditingButton(false);
setEditingButtonPos(null);
}
}}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{__('Insert Button')}</DialogTitle> <DialogTitle>{isEditingButton ? __('Edit Button') : __('Insert Button')}</DialogTitle>
<DialogDescription> <DialogDescription>
{__('Add a styled button to your content. Use variables for dynamic links.')} {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> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -440,12 +513,17 @@ export function RichTextEditor({
</div> </div>
</DialogBody> </DialogBody>
<DialogFooter> <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)}> <Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
{__('Cancel')} {__('Cancel')}
</Button> </Button>
<Button onClick={insertButton}> <Button onClick={insertButton}>
{__('Insert Button')} {isEditingButton ? __('Update Button') : __('Insert Button')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -66,45 +66,23 @@ export const ButtonExtension = Node.create<ButtonOptions>({
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
const { text, href, style } = HTMLAttributes; const { text, href, style } = HTMLAttributes;
const className = style === 'outline' ? 'button-outline' : 'button';
const buttonStyle: Record<string, string> = style === 'solid'
? {
display: 'inline-block',
background: '#7f54b3',
color: '#fff',
padding: '14px 28px',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: '600',
cursor: 'pointer',
}
: {
display: 'inline-block',
background: 'transparent',
color: '#7f54b3',
padding: '12px 26px',
border: '2px solid #7f54b3',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: '600',
cursor: 'pointer',
};
// Simple link styling - no fancy button appearance in editor
// The actual button styling happens in email rendering (EmailRenderer.php)
// In editor, just show as a link with visual indicator it's a button
return [ return [
'a', 'a',
mergeAttributes(this.options.HTMLAttributes, { mergeAttributes(this.options.HTMLAttributes, {
href, href,
class: className, class: 'button-node',
style: Object.entries(buttonStyle) style: 'color: #7f54b3; text-decoration: underline; cursor: pointer; font-weight: 500;',
.map(([key, value]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value}`)
.join('; '),
'data-button': '', 'data-button': '',
'data-text': text, 'data-text': text,
'data-href': href, 'data-href': href,
'data-style': style, 'data-style': style,
title: `Button: ${text}${href}`,
}), }),
text, `🔘 ${text}`,
]; ];
}, },