Qassure-frontend/src/components/shared/RichTextEditor.tsx

478 lines
16 KiB
TypeScript

import { useEffect, useMemo, useState, type ReactElement } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Extension } from "@tiptap/core";
import Underline from "@tiptap/extension-underline";
import TextAlign from "@tiptap/extension-text-align";
import Link from "@tiptap/extension-link";
import { TextStyle } from "@tiptap/extension-text-style";
import Color from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import Placeholder from "@tiptap/extension-placeholder";
import FontFamily from "@tiptap/extension-font-family";
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold,
ChevronDown,
ChevronUp,
Eraser,
Highlighter,
IndentDecrease,
IndentIncrease,
Italic,
Link2,
List,
ListOrdered,
RemoveFormatting,
Quote,
Redo2,
Strikethrough,
Underline as UnderlineIcon,
Undo2,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface RichTextEditorProps {
label: string;
value: string;
onChange: (html: string, text: string) => void;
placeholder?: string;
required?: boolean;
error?: string;
minHeightClassName?: string;
}
const FontSize = Extension.create({
name: "fontSize",
addGlobalAttributes() {
return [
{
types: ["textStyle"],
attributes: {
fontSize: {
default: null,
parseHTML: (element: HTMLElement) => element.style.fontSize || null,
renderHTML: (attributes: Record<string, string>) => {
if (!attributes.fontSize) return {};
return { style: `font-size: ${attributes.fontSize}` };
},
},
},
},
];
},
addCommands() {
return {
setFontSize:
(fontSize: string) =>
({ chain }: { chain: any }) => {
return chain().setMark("textStyle", { fontSize }).run();
},
unsetFontSize:
() =>
({ chain }: { chain: any }) => {
return chain().setMark("textStyle", { fontSize: null }).run();
},
} as any;
},
});
const ToolButton = ({
isActive,
onClick,
title,
children,
}: {
isActive?: boolean;
onClick: () => void;
title: string;
children: React.ReactNode;
}): ReactElement => (
<button
type="button"
onMouseDown={(event) => {
// Keep editor selection/focus while clicking toolbar actions.
event.preventDefault();
}}
onClick={onClick}
title={title}
className={cn(
"h-8 w-8 rounded border text-[#334155] flex items-center justify-center",
isActive
? "bg-[#112868]/10 border-[#112868]/20"
: "border-transparent hover:border-[rgba(0,0,0,0.08)] hover:bg-white",
)}
>
{children}
</button>
);
export const RichTextEditor = ({
label,
value,
onChange,
placeholder = "Start writing...",
required = false,
error,
minHeightClassName = "min-h-[300px]",
}: RichTextEditorProps): ReactElement => {
const [showMoreTools, setShowMoreTools] = useState(false);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Underline,
TextStyle,
FontFamily.configure({
types: ["textStyle"],
}),
FontSize,
Color,
Highlight.configure({
multicolor: true,
}),
Link.configure({
openOnClick: false,
autolink: true,
}),
TextAlign.configure({
types: ["heading", "paragraph"],
}),
Placeholder.configure({
placeholder,
}),
],
content: value || "",
onUpdate: ({ editor: currentEditor }) => {
onChange(currentEditor.getHTML(), currentEditor.getText());
},
});
useEffect(() => {
if (!editor) return;
if (value !== editor.getHTML()) {
editor.commands.setContent(value || "", { emitUpdate: false });
}
}, [value, editor]);
const activeBlock = useMemo((): string => {
if (!editor) return "paragraph";
if (editor.isActive("heading", { level: 1 })) return "h1";
if (editor.isActive("heading", { level: 2 })) return "h2";
if (editor.isActive("heading", { level: 3 })) return "h3";
return "paragraph";
}, [editor, editor?.state]);
const activeFontFamily = editor?.getAttributes("textStyle").fontFamily || "Verdana";
const activeFontSize = editor?.getAttributes("textStyle").fontSize || "12pt";
const applyBlockStyle = (value: string): void => {
if (!editor) return;
const chain = editor.chain().focus();
if (value === "paragraph") {
chain.setParagraph().run();
return;
}
const level = Number(value.replace("h", ""));
if ([1, 2, 3].includes(level)) {
chain.setHeading({ level: level as 1 | 2 | 3 }).run();
}
};
return (
<div className="flex flex-col gap-2 pb-1">
<label className="flex items-center gap-1 text-[13px] font-medium text-[#0e1b2a]">
<span>{label}</span>
{required && <span className="text-[#e02424]">*</span>}
</label>
<div
className={cn(
"border rounded-md overflow-hidden bg-white",
error ? "border-[#ef4444]" : "border-[rgba(0,0,0,0.08)]",
)}
>
<div className="flex flex-wrap items-center gap-1 p-2 border-b border-[rgba(0,0,0,0.08)] bg-[#f8fafc]">
<ToolButton
title="Plain Text"
onClick={() =>
editor
?.chain()
.focus()
.unsetAllMarks()
.clearNodes()
.setParagraph()
.unsetColor()
.run()
}
>
<RemoveFormatting className="w-3.5 h-3.5" />
</ToolButton>
<div className="h-5 w-px bg-[rgba(0,0,0,0.08)] mx-1" />
<ToolButton
title="Bold"
isActive={editor?.isActive("bold")}
onClick={() => editor?.chain().focus().toggleBold().run()}
>
<Bold className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Italic"
isActive={editor?.isActive("italic")}
onClick={() => editor?.chain().focus().toggleItalic().run()}
>
<Italic className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Underline"
isActive={editor?.isActive("underline")}
onClick={() => editor?.chain().focus().toggleUnderline().run()}
>
<UnderlineIcon className="w-3.5 h-3.5" />
</ToolButton>
<div className="h-5 w-px bg-[rgba(0,0,0,0.08)] mx-1" />
<ToolButton
title="Align Left"
isActive={editor?.isActive({ textAlign: "left" })}
onClick={() => editor?.chain().focus().setTextAlign("left").run()}
>
<AlignLeft className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Align Center"
isActive={editor?.isActive({ textAlign: "center" })}
onClick={() => editor?.chain().focus().setTextAlign("center").run()}
>
<AlignCenter className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Align Right"
isActive={editor?.isActive({ textAlign: "right" })}
onClick={() => editor?.chain().focus().setTextAlign("right").run()}
>
<AlignRight className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Justify"
isActive={editor?.isActive({ textAlign: "justify" })}
onClick={() => editor?.chain().focus().setTextAlign("justify").run()}
>
<AlignJustify className="w-3.5 h-3.5" />
</ToolButton>
<div className="h-5 w-px bg-[rgba(0,0,0,0.08)] mx-1" />
<select
value={activeBlock}
onChange={(event) => applyBlockStyle(event.target.value)}
className="h-8 px-2 rounded border border-[rgba(0,0,0,0.08)] bg-white text-xs text-[#334155]"
title="Block style"
>
<option value="paragraph">Paragraph</option>
<option value="h1">Heading 1</option>
<option value="h2">Heading 2</option>
<option value="h3">Heading 3</option>
</select>
<select
value={activeFontFamily}
onChange={(event) =>
editor?.chain().focus().setFontFamily(event.target.value).run()
}
className="h-8 px-2 rounded border border-[rgba(0,0,0,0.08)] bg-white text-xs text-[#334155]"
title="Font family"
>
<option value="Verdana">Verdana</option>
<option value="Arial">Arial</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Georgia">Georgia</option>
<option value="Tahoma">Tahoma</option>
</select>
<select
value={activeFontSize}
onChange={(event) =>
(editor as any)?.chain().focus().setFontSize(event.target.value).run()
}
className="h-8 px-2 rounded border border-[rgba(0,0,0,0.08)] bg-white text-xs text-[#334155]"
title="Font size"
>
<option value="10pt">10pt</option>
<option value="11pt">11pt</option>
<option value="12pt">12pt</option>
<option value="14pt">14pt</option>
<option value="16pt">16pt</option>
<option value="18pt">18pt</option>
<option value="24pt">24pt</option>
</select>
<div className="h-5 w-px bg-[rgba(0,0,0,0.08)] mx-1" />
<input
type="color"
title="Text Color"
className="h-8 w-8 rounded border border-[rgba(0,0,0,0.08)] bg-white cursor-pointer"
onChange={(event) =>
editor?.chain().focus().setColor(event.target.value).run()
}
/>
<input
type="color"
title="Background Color"
className="h-8 w-8 rounded border border-[rgba(0,0,0,0.08)] bg-white cursor-pointer"
onChange={(event) =>
editor
?.chain()
.focus()
.setHighlight({ color: event.target.value })
.run()
}
/>
<ToolButton
title="More Options"
onClick={() => setShowMoreTools((prev) => !prev)}
>
{showMoreTools ? (
<ChevronUp className="w-3.5 h-3.5" />
) : (
<ChevronDown className="w-3.5 h-3.5" />
)}
</ToolButton>
</div>
{showMoreTools && (
<div className="flex flex-wrap items-center gap-1 p-2 border-b border-[rgba(0,0,0,0.08)] bg-[#f1f5f9]">
<ToolButton
title="Bullet List"
isActive={editor?.isActive("bulletList")}
onClick={() => editor?.chain().focus().toggleBulletList().run()}
>
<List className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Numbered List"
isActive={editor?.isActive("orderedList")}
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
>
<ListOrdered className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Decrease Indent"
onClick={() =>
editor?.chain().focus().liftListItem("listItem").run()
}
>
<IndentDecrease className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Increase Indent"
onClick={() =>
editor?.chain().focus().sinkListItem("listItem").run()
}
>
<IndentIncrease className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Blockquote"
isActive={editor?.isActive("blockquote")}
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
>
<Quote className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Insert Link"
isActive={editor?.isActive("link")}
onClick={() => {
if (!editor) return;
const selection = editor.state.selection;
const url = window.prompt("Enter URL");
if (url) {
const normalizedUrl = /^https?:\/\//i.test(url)
? url
: `https://${url}`;
const isSelectionEmpty = selection.empty;
const from = selection.from;
const to = selection.to;
if (isSelectionEmpty) {
editor
.chain()
.focus()
.setTextSelection({ from, to })
.insertContent(normalizedUrl)
.setTextSelection({
from,
to: from + normalizedUrl.length,
})
.setLink({ href: normalizedUrl })
.run();
return;
}
editor
.chain()
.focus()
.setTextSelection({ from, to })
.extendMarkRange("link")
.setLink({ href: normalizedUrl })
.run();
}
}}
>
<Link2 className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Remove Link"
onClick={() => editor?.chain().focus().unsetLink().run()}
>
<Link2 className="w-3.5 h-3.5 opacity-60" />
</ToolButton>
<ToolButton
title="Highlight"
isActive={editor?.isActive("highlight")}
onClick={() => editor?.chain().focus().toggleHighlight().run()}
>
<Highlighter className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Strike"
isActive={editor?.isActive("strike")}
onClick={() => editor?.chain().focus().toggleStrike().run()}
>
<Strikethrough className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Clear Formatting"
onClick={() =>
editor?.chain().focus().unsetAllMarks().clearNodes().run()
}
>
<Eraser className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Undo"
onClick={() => editor?.chain().focus().undo().run()}
>
<Undo2 className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Redo"
onClick={() => editor?.chain().focus().redo().run()}
>
<Redo2 className="w-3.5 h-3.5" />
</ToolButton>
</div>
)}
<EditorContent
editor={editor}
className={cn(
"p-3 text-sm text-[#0f1724] [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[inherit] [&_.ProseMirror]:leading-6 [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-[#9aa6b2] [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_h1]:text-[30px] [&_.ProseMirror_h1]:font-bold [&_.ProseMirror_h1]:leading-tight [&_.ProseMirror_h1]:my-2 [&_.ProseMirror_h2]:text-[24px] [&_.ProseMirror_h2]:font-semibold [&_.ProseMirror_h2]:leading-tight [&_.ProseMirror_h2]:my-2 [&_.ProseMirror_h3]:text-[20px] [&_.ProseMirror_h3]:font-semibold [&_.ProseMirror_h3]:leading-tight [&_.ProseMirror_h3]:my-2 [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-6 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_li]:my-1 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-[#cbd5e1] [&_.ProseMirror_blockquote]:pl-3 [&_.ProseMirror_blockquote]:text-[#475569] [&_.ProseMirror_a]:text-[#084cc8] [&_.ProseMirror_a]:underline",
minHeightClassName,
)}
/>
</div>
{error && <p className="text-sm text-[#ef4444]">{error}</p>}
</div>
);
};