The rich-text editor was the last hardcoded-English surface in the composer: 21 tooltip titles, the eight table-menu entries, the "Remove color" entry and the table size picker's "Pick size" label were plain strings while every other menu in the app is localized. All of them now come from a new email_composer.toolbar namespace, translated into all 23 locales using each platform's established editor terminology (Word/Docs conventions - de "Formatierung löschen", ar "مسح التنسيق", ja "書式をクリア", ...). The link prompt stays "URL", which is the same term in every language. Tooltips and self-sizing dropdowns have no width constraints, so longer translations are safe everywhere.
671 lines
24 KiB
TypeScript
671 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import React, { useEffect, useCallback, useState, useRef } from "react";
|
||
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
|
||
import StarterKit from "@tiptap/starter-kit";
|
||
import Paragraph from "@tiptap/extension-paragraph";
|
||
import Heading from "@tiptap/extension-heading";
|
||
import Underline from "@tiptap/extension-underline";
|
||
import Link from "@tiptap/extension-link";
|
||
import TextAlign from "@tiptap/extension-text-align";
|
||
import { TextDirection } from "@/components/email/text-direction";
|
||
import { TextStyle } from "@tiptap/extension-text-style";
|
||
import Color from "@tiptap/extension-color";
|
||
import { ResizableImage } from "@/components/email/resizable-image";
|
||
import Placeholder from "@tiptap/extension-placeholder";
|
||
import { Table } from "@tiptap/extension-table";
|
||
import { TableRow } from "@tiptap/extension-table-row";
|
||
import { TableHeader } from "@tiptap/extension-table-header";
|
||
import { TableCell } from "@tiptap/extension-table-cell";
|
||
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
||
import { SignatureBlock } from "@/components/email/signature-block";
|
||
import { cn } from "@/lib/utils";
|
||
import { useSettingsStore } from "@/stores/settings-store";
|
||
import { useTranslations } from "next-intl";
|
||
import {
|
||
Bold,
|
||
Italic,
|
||
Underline as UnderlineIcon,
|
||
Strikethrough,
|
||
List,
|
||
ListOrdered,
|
||
AlignLeft,
|
||
AlignCenter,
|
||
AlignRight,
|
||
ArrowLeftRight,
|
||
Link as LinkIcon,
|
||
Undo,
|
||
Redo,
|
||
Quote,
|
||
Code,
|
||
RemoveFormatting,
|
||
Heading1,
|
||
Heading2,
|
||
Table as TableIcon,
|
||
Baseline,
|
||
Trash2,
|
||
Rows3,
|
||
Columns3,
|
||
} from "lucide-react";
|
||
|
||
export interface InlineImageUpload {
|
||
src: string;
|
||
cid?: string;
|
||
}
|
||
|
||
// Pasted email content (signatures, replies, quoted text) commonly carries
|
||
// inline styles on block elements. StarterKit's default Paragraph/Heading
|
||
// drop unknown attributes; extend them to round-trip `style` and `class` so
|
||
// signature formatting survives the editor.
|
||
const styledBlockAttributes = {
|
||
style: {
|
||
default: null as string | null,
|
||
parseHTML: (el: HTMLElement) => el.getAttribute("style"),
|
||
renderHTML: (attrs: Record<string, string | null>) =>
|
||
attrs.style ? { style: attrs.style } : {},
|
||
},
|
||
class: {
|
||
default: null as string | null,
|
||
parseHTML: (el: HTMLElement) => el.getAttribute("class"),
|
||
renderHTML: (attrs: Record<string, string | null>) =>
|
||
attrs.class ? { class: attrs.class } : {},
|
||
},
|
||
"data-signature-block": {
|
||
default: null as string | null,
|
||
parseHTML: (el: HTMLElement) => el.getAttribute("data-signature-block"),
|
||
renderHTML: (attrs: Record<string, string | null>) =>
|
||
attrs["data-signature-block"]
|
||
? { "data-signature-block": attrs["data-signature-block"] }
|
||
: {},
|
||
},
|
||
};
|
||
|
||
const StyledParagraph = Paragraph.extend({
|
||
addAttributes() {
|
||
return {
|
||
...this.parent?.(),
|
||
...styledBlockAttributes,
|
||
};
|
||
},
|
||
});
|
||
|
||
const StyledHeading = Heading.extend({
|
||
addAttributes() {
|
||
return {
|
||
...this.parent?.(),
|
||
...styledBlockAttributes,
|
||
};
|
||
},
|
||
});
|
||
|
||
interface RichTextEditorProps {
|
||
content: string;
|
||
onChange: (html: string) => void;
|
||
onImageUpload?: (file: File) => Promise<InlineImageUpload | null>;
|
||
placeholder?: string;
|
||
className?: string;
|
||
hasError?: boolean;
|
||
onEditorReady?: (editor: Editor) => void;
|
||
}
|
||
|
||
function ToolbarButton({
|
||
active,
|
||
onClick,
|
||
children,
|
||
title,
|
||
disabled,
|
||
}: {
|
||
active?: boolean;
|
||
onClick: () => void;
|
||
children: React.ReactNode;
|
||
title: string;
|
||
disabled?: boolean;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
title={title}
|
||
className={cn(
|
||
"p-1.5 rounded hover:bg-accent transition-colors",
|
||
active && "bg-accent text-accent-foreground",
|
||
disabled && "opacity-40 cursor-not-allowed"
|
||
)}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function ToolbarSeparator() {
|
||
return <div className="w-px h-5 bg-border mx-0.5" />;
|
||
}
|
||
|
||
const TABLE_PICKER_ROWS = 6;
|
||
const TABLE_PICKER_COLS = 8;
|
||
|
||
// Preset text colours (2 x 8). Inline `style="color: …"` survives email
|
||
// round-trips; the TextStyle/Color extensions are already registered to
|
||
// preserve pasted colours - this palette just adds a UI to set them.
|
||
const TEXT_COLORS = [
|
||
"#000000", "#5f6368", "#9aa0a6", "#c5221f", "#e8710a", "#f9ab00", "#188038", "#1967d2",
|
||
"#7627bb", "#c2185b", "#795548", "#fa5252", "#fd7e14", "#40c057", "#4dabf7", "#e64980",
|
||
];
|
||
|
||
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
||
const t = useTranslations("email_composer.toolbar");
|
||
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
|
||
return (
|
||
<div>
|
||
<div
|
||
className="grid gap-0.5"
|
||
style={{ gridTemplateColumns: `repeat(${TABLE_PICKER_COLS}, 1fr)` }}
|
||
onMouseLeave={() => setHover(null)}
|
||
>
|
||
{Array.from({ length: TABLE_PICKER_ROWS * TABLE_PICKER_COLS }).map((_, i) => {
|
||
const r = Math.floor(i / TABLE_PICKER_COLS);
|
||
const c = i % TABLE_PICKER_COLS;
|
||
const active = hover && r <= hover.r && c <= hover.c;
|
||
return (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
onMouseEnter={() => setHover({ r, c })}
|
||
onClick={() => onPick(r + 1, c + 1)}
|
||
className={cn(
|
||
"w-4 h-4 border border-border/60 rounded-[2px] transition-colors",
|
||
active ? "bg-primary border-primary" : "bg-background hover:bg-accent"
|
||
)}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground mt-1.5 text-center">
|
||
{hover ? `${hover.r + 1} × ${hover.c + 1}` : t("pick_size")}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function RichTextEditor({
|
||
content,
|
||
onChange,
|
||
onImageUpload,
|
||
placeholder,
|
||
className,
|
||
hasError,
|
||
onEditorReady,
|
||
}: RichTextEditorProps) {
|
||
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
|
||
const onImageUploadRef = React.useRef(onImageUpload);
|
||
onImageUploadRef.current = onImageUpload;
|
||
const onEditorReadyRef = React.useRef(onEditorReady);
|
||
onEditorReadyRef.current = onEditorReady;
|
||
|
||
const editor = useEditor({
|
||
extensions: [
|
||
StarterKit.configure({
|
||
heading: false,
|
||
paragraph: false,
|
||
link: false,
|
||
underline: false,
|
||
}),
|
||
StyledParagraph,
|
||
StyledHeading.configure({ levels: [1, 2] }),
|
||
Underline,
|
||
Link.configure({
|
||
openOnClick: false,
|
||
HTMLAttributes: { rel: "noopener noreferrer nofollow" },
|
||
}),
|
||
TextAlign.configure({
|
||
types: ["heading", "paragraph"],
|
||
}),
|
||
TextStyle,
|
||
Color,
|
||
ResizableImage,
|
||
Placeholder.configure({
|
||
placeholder,
|
||
}),
|
||
Table.configure({
|
||
resizable: true,
|
||
HTMLAttributes: {
|
||
border: "1",
|
||
cellpadding: "6",
|
||
cellspacing: "0",
|
||
width: "100%",
|
||
style: "width:100%;border-collapse:collapse;",
|
||
},
|
||
}),
|
||
TableRow,
|
||
TableHeader.configure({
|
||
HTMLAttributes: {
|
||
style: "padding:6px 8px;border:1px solid #ccc;background-color:#f5f5f5;color:#1f2937;text-align:left;",
|
||
},
|
||
}),
|
||
TableCell.configure({
|
||
HTMLAttributes: {
|
||
style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;",
|
||
},
|
||
}),
|
||
// Quoted/forwarded original email body - held verbatim as an atomic
|
||
// node so layout-heavy HTML survives 1:1 (see quoted-html.ts).
|
||
QuotedHtml,
|
||
// Identity signature - held verbatim as a non-editable atomic node so
|
||
// rich/branded signatures keep their inline styling in the editor and
|
||
// in the sent mail (see signature-block.ts).
|
||
SignatureBlock,
|
||
TextDirection,
|
||
],
|
||
content,
|
||
editorProps: {
|
||
attributes: {
|
||
class: "tiptap min-h-[100px] px-4 py-3 text-sm text-foreground",
|
||
},
|
||
handleDrop: (view, event) => {
|
||
const upload = onImageUploadRef.current;
|
||
if (!upload || !event.dataTransfer?.files?.length) return false;
|
||
const imageFiles = Array.from(event.dataTransfer.files).filter(f =>
|
||
f.type.startsWith("image/")
|
||
);
|
||
if (imageFiles.length === 0) return false;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
for (const file of imageFiles) {
|
||
upload(file).then((result) => {
|
||
if (result) {
|
||
const { state } = view;
|
||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||
const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
|
||
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
|
||
view.dispatch(tr);
|
||
}
|
||
});
|
||
}
|
||
return true;
|
||
},
|
||
handlePaste: (view, event) => {
|
||
const upload = onImageUploadRef.current;
|
||
if (!upload || !event.clipboardData?.files?.length) return false;
|
||
const imageFiles = Array.from(event.clipboardData.files).filter(f =>
|
||
f.type.startsWith("image/")
|
||
);
|
||
if (imageFiles.length === 0) return false;
|
||
event.preventDefault();
|
||
for (const file of imageFiles) {
|
||
upload(file).then((result) => {
|
||
if (result) {
|
||
const { state } = view;
|
||
const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
|
||
const tr = state.tr.replaceSelectionWith(node);
|
||
view.dispatch(tr);
|
||
}
|
||
});
|
||
}
|
||
return true;
|
||
},
|
||
},
|
||
onUpdate: ({ editor }) => {
|
||
// serializeEditorContent (not getHTML) so the verbatim quoted-original
|
||
// HTML held in the QuotedHtml atom node is emitted intact.
|
||
onChange(serializeEditorContent(editor));
|
||
},
|
||
immediatelyRender: false,
|
||
});
|
||
|
||
// Sync external content changes (e.g. template application). Compare against
|
||
// the custom serialization so a doc that only differs inside a QuotedHtml
|
||
// island isn't needlessly re-parsed (which would reset the island DOM).
|
||
useEffect(() => {
|
||
if (editor && content !== serializeEditorContent(editor)) {
|
||
editor.commands.setContent(content, { emitUpdate: false });
|
||
}
|
||
}, [content, editor]);
|
||
|
||
// Expose the editor instance once it's ready so parents can target
|
||
// specific nodes (e.g. swap the embedded signature on identity change).
|
||
useEffect(() => {
|
||
if (editor) onEditorReadyRef.current?.(editor);
|
||
}, [editor]);
|
||
|
||
const addLink = useCallback(() => {
|
||
if (!editor) return;
|
||
const previousUrl = editor.getAttributes("link").href;
|
||
const url = window.prompt("URL", previousUrl);
|
||
if (url === null) return;
|
||
if (url === "") {
|
||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||
return;
|
||
}
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.extendMarkRange("link")
|
||
.setLink({ href: url })
|
||
.run();
|
||
}, [editor]);
|
||
|
||
const tToolbar = useTranslations("email_composer.toolbar");
|
||
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||
const [colorMenuOpen, setColorMenuOpen] = useState(false);
|
||
const colorWrapperRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!colorMenuOpen) return;
|
||
const handler = (e: MouseEvent) => {
|
||
if (colorWrapperRef.current && !colorWrapperRef.current.contains(e.target as Node)) {
|
||
setColorMenuOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handler);
|
||
return () => document.removeEventListener("mousedown", handler);
|
||
}, [colorMenuOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!tableMenuOpen) return;
|
||
const handler = (e: MouseEvent) => {
|
||
if (tableWrapperRef.current && !tableWrapperRef.current.contains(e.target as Node)) {
|
||
setTableMenuOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handler);
|
||
return () => document.removeEventListener("mousedown", handler);
|
||
}, [tableMenuOpen]);
|
||
|
||
if (!editor) {
|
||
return (
|
||
<div className={cn("min-h-[100px]", className)} />
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={cn("flex flex-col", hasError && "ring-2 ring-red-500 dark:ring-red-400 rounded", className)}>
|
||
{/* Toolbar */}
|
||
<div className="flex flex-wrap items-center gap-0.5 px-3 py-1.5 border-b border-border/50 bg-muted/30">
|
||
<ToolbarButton
|
||
active={editor.isActive("bold")}
|
||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||
title={tToolbar("bold")}
|
||
>
|
||
<Bold className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
<ToolbarButton
|
||
active={editor.isActive("italic")}
|
||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||
title={tToolbar("italic")}
|
||
>
|
||
<Italic className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
<ToolbarButton
|
||
active={editor.isActive("underline")}
|
||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||
title={tToolbar("underline")}
|
||
>
|
||
<UnderlineIcon className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
<ToolbarButton
|
||
active={editor.isActive("strike")}
|
||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||
title={tToolbar("strikethrough")}
|
||
>
|
||
<Strikethrough className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
<div ref={colorWrapperRef} className="relative">
|
||
<ToolbarButton
|
||
active={!!editor.getAttributes("textStyle").color}
|
||
onClick={() => setColorMenuOpen((v) => !v)}
|
||
title={tToolbar("text_color")}
|
||
>
|
||
{/* The icon itself previews the active colour - no layout shift. */}
|
||
<Baseline className="w-4 h-4" style={{ color: editor.getAttributes("textStyle").color || undefined }} />
|
||
</ToolbarButton>
|
||
{colorMenuOpen && (
|
||
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2">
|
||
<div className="grid gap-0.5" style={{ gridTemplateColumns: "repeat(8, 1fr)" }}>
|
||
{TEXT_COLORS.map((color) => (
|
||
<button
|
||
key={color}
|
||
type="button"
|
||
title={color}
|
||
onClick={() => {
|
||
editor.chain().focus().setColor(color).run();
|
||
setColorMenuOpen(false);
|
||
}}
|
||
className={cn(
|
||
"w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110",
|
||
editor.getAttributes("textStyle").color === color && "ring-1 ring-ring ring-offset-1"
|
||
)}
|
||
style={{ backgroundColor: color }}
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className="h-px bg-border my-1.5" />
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-2 px-2 py-1 text-sm rounded hover:bg-accent text-start w-full"
|
||
onClick={() => {
|
||
editor.chain().focus().unsetColor().run();
|
||
setColorMenuOpen(false);
|
||
}}
|
||
>
|
||
<RemoveFormatting className="w-4 h-4" /> {tToolbar("remove_color")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<ToolbarSeparator />
|
||
|
||
<ToolbarButton
|
||
active={editor.isActive("heading", { level: 1 })}
|
||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||
title={tToolbar("heading_1")}
|
||
>
|
||
<Heading1 className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
<ToolbarButton
|
||
active={editor.isActive("heading", { level: 2 })}
|
||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||
title={tToolbar("heading_2")}
|
||
>
|
||
<Heading2 className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
|
||
<ToolbarSeparator />
|
||
|
||
<ToolbarButton
|
||
active={editor.isActive("bulletList")}
|
||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||
title={tToolbar("bullet_list")}
|
||
>
|
||
<List className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
<ToolbarButton
|
||
active={editor.isActive("orderedList")}
|
||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||
title={tToolbar("ordered_list")}
|
||
>
|
||
<ListOrdered className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
<ToolbarButton
|
||
active={editor.isActive("blockquote")}
|
||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||
title={tToolbar("quote")}
|
||
>
|
||
<Quote className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
<ToolbarButton
|
||
active={editor.isActive("codeBlock")}
|
||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||
title={tToolbar("code_block")}
|
||
>
|
||
<Code className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
|
||
<ToolbarSeparator />
|
||
|
||
<ToolbarButton
|
||
active={editor.isActive({ textAlign: "left" })}
|
||
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||
title={tToolbar("align_left")}
|
||
>
|
||
<AlignLeft className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
<ToolbarButton
|
||
active={editor.isActive({ textAlign: "center" })}
|
||
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
||
title={tToolbar("align_center")}
|
||
>
|
||
<AlignCenter className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
<ToolbarButton
|
||
active={editor.isActive({ textAlign: "right" })}
|
||
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||
title={tToolbar("align_right")}
|
||
>
|
||
<AlignRight className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
|
||
{rtlEditingSupport && (
|
||
<ToolbarButton
|
||
active={
|
||
(editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir) === "rtl"
|
||
}
|
||
onClick={() => {
|
||
const cur =
|
||
editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir;
|
||
editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run();
|
||
}}
|
||
title={tToolbar("text_direction")}
|
||
>
|
||
<ArrowLeftRight className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
)}
|
||
|
||
<ToolbarSeparator />
|
||
|
||
<ToolbarButton
|
||
active={editor.isActive("link")}
|
||
onClick={addLink}
|
||
title={tToolbar("link")}
|
||
>
|
||
<LinkIcon className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
|
||
<div ref={tableWrapperRef} className="relative">
|
||
<ToolbarButton
|
||
active={editor.isActive("table")}
|
||
onClick={() => setTableMenuOpen((v) => !v)}
|
||
title={tToolbar("table")}
|
||
>
|
||
<TableIcon className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
{tableMenuOpen && (
|
||
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
|
||
{editor.isActive("table") ? (
|
||
<div className="flex flex-col gap-0.5">
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
|
||
>
|
||
<Rows3 className="w-4 h-4" /> {tToolbar("add_row_above")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
|
||
>
|
||
<Rows3 className="w-4 h-4" /> {tToolbar("add_row_below")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
|
||
>
|
||
<Columns3 className="w-4 h-4" /> {tToolbar("add_column_before")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
|
||
>
|
||
<Columns3 className="w-4 h-4" /> {tToolbar("add_column_after")}
|
||
</button>
|
||
<div className="h-px bg-border my-1" />
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
|
||
>
|
||
<Trash2 className="w-4 h-4" /> {tToolbar("delete_row")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
|
||
>
|
||
<Trash2 className="w-4 h-4" /> {tToolbar("delete_column")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
|
||
>
|
||
<Rows3 className="w-4 h-4" /> {tToolbar("toggle_header_row")}
|
||
</button>
|
||
<div className="h-px bg-border my-1" />
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start text-red-600 dark:text-red-400"
|
||
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
|
||
>
|
||
<Trash2 className="w-4 h-4" /> {tToolbar("delete_table")}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<TableSizePicker
|
||
onPick={(rows, cols) => {
|
||
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
|
||
setTableMenuOpen(false);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<ToolbarSeparator />
|
||
|
||
<ToolbarButton
|
||
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||
title={tToolbar("clear_formatting")}
|
||
>
|
||
<RemoveFormatting className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
|
||
<ToolbarSeparator />
|
||
|
||
<ToolbarButton
|
||
onClick={() => editor.chain().focus().undo().run()}
|
||
disabled={!editor.can().undo()}
|
||
title={tToolbar("undo")}
|
||
>
|
||
<Undo className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
<ToolbarButton
|
||
onClick={() => editor.chain().focus().redo().run()}
|
||
disabled={!editor.can().redo()}
|
||
title={tToolbar("redo")}
|
||
>
|
||
<Redo className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
</div>
|
||
|
||
{/* Editor */}
|
||
<EditorContent editor={editor} />
|
||
</div>
|
||
);
|
||
}
|