Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/components/ui/input/InputRichTextEditor.client.tsx
T

320 lines
11 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Editor, EditorContent, Extensions, useEditor } from "@tiptap/react";
import { Button } from "../button";
import { Bold, Italic, Strikethrough, Code, List, ListOrdered, Quote, Undo, Redo, Eraser, Link2, Underline, PilcrowIcon } from "~/components/ui/icons/vendor";
import Color from "@tiptap/extension-color";
import { createTiptapEditorProps } from "~/modules/novel/ui/editor/props";
import { createTiptapExtensions } from "~/modules/novel/ui/editor/extensions";
import { cn } from "~/lib/utils";
import { useRootData } from "~/utils/data/useRootData";
function MenuBar({ editor, disabled }: { editor: Editor | null; disabled?: boolean }) {
const { t } = useTranslation();
const headingLevels: (1 | 2 | 3)[] = [1, 2, 3];
return (
<div className="flex flex-wrap gap-1">
<Button
variant="ghost"
size="xs"
type="button"
onClick={() => editor?.chain().focus().toggleBold().run()}
disabled={disabled || !editor?.can().chain().focus().toggleBold().run()}
className={editor?.isActive("bold") ? "bg-surface-neutral-secondary" : ""}
title={t("components.richTextEditor.toolbar.bold")}
>
<Bold size={16} />
</Button>
<Button
variant="ghost"
size="xs"
type="button"
onClick={() => editor?.chain().focus().toggleItalic().run()}
disabled={disabled || !editor?.can().chain().focus().toggleItalic().run()}
className={editor?.isActive("italic") ? "bg-surface-neutral-secondary" : ""}
title={t("components.richTextEditor.toolbar.italic")}
>
<Italic size={16} />
</Button>
{/* Underline */}
<Button
variant="ghost"
size="xs"
type="button"
onClick={() => editor?.chain().focus().toggleUnderline().run()}
title={t("components.richTextEditor.toolbar.underline")}
className={editor?.isActive("underline") ? "bg-surface-neutral-secondary" : ""}
disabled={disabled || !editor?.can().chain().focus().toggleUnderline().run()}
>
<Underline size={16} />
</Button>
<Button
variant="ghost"
size="xs"
type="button"
onClick={() => editor?.chain().focus().toggleStrike().run()}
disabled={disabled || !editor?.can().chain().focus().toggleStrike().run()}
className={editor?.isActive("strike") ? "bg-surface-neutral-secondary" : ""}
title={t("components.richTextEditor.toolbar.strikethrough")}
>
<Strikethrough size={16} />
</Button>
<Button
variant="ghost"
size="xs"
type="button"
onClick={() => editor?.chain().focus().toggleCode().run()}
disabled={disabled || !editor?.can().chain().focus().toggleCode().run()}
className={editor?.isActive("code") ? "bg-surface-neutral-secondary" : ""}
title={t("components.richTextEditor.toolbar.code")}
>
<Code size={16} />
</Button>
{headingLevels.map((level) => (
<Button
key={level}
variant="ghost"
size="xs"
type="button"
onClick={() => editor?.chain().focus().toggleHeading({ level }).run()}
title={t("components.richTextEditor.toolbar.heading", { level })}
className={editor?.isActive("heading", { level }) ? "bg-surface-neutral-secondary" : ""}
disabled={disabled || !editor?.can().chain().focus().toggleHeading({ level }).run()}
>
H{level}
</Button>
))}
<Button
variant="ghost"
size="xs"
type="button"
onClick={() => editor?.chain().focus().setParagraph().run()}
className={editor?.isActive("paragraph") ? "bg-surface-neutral-secondary" : ""}
title={t("components.richTextEditor.toolbar.paragraph")}
disabled={disabled || !editor?.can().chain().focus().setParagraph().run()}
>
<PilcrowIcon className="h-4 w-4" />
</Button>
{/* Link Button */}
<Button
variant="ghost"
size="xs"
type="button"
onClick={() => {
const previousUrl = editor?.getAttributes("link").href;
const url = window.prompt(t("components.richTextEditor.toolbar.enterUrl"), previousUrl);
if (url === null) return;
if (url === "") {
editor?.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
editor?.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
}}
className={editor?.isActive("link") ? "bg-surface-neutral-secondary" : ""}
title={t("components.richTextEditor.toolbar.link")}
disabled={disabled || !editor?.can().chain().focus().extendMarkRange("link").run()}
>
<Link2 size={16} />
</Button>
<Button
variant="ghost"
size="xs"
type="button"
onClick={() => editor?.chain().focus().toggleBulletList().run()}
className={editor?.isActive("bulletList") ? "bg-surface-neutral-secondary" : ""}
title={t("components.richTextEditor.toolbar.bulletList")}
disabled={disabled || !editor?.can().chain().focus().toggleBulletList().run()}
>
<List size={16} />
</Button>
<Button
variant="ghost"
size="xs"
type="button"
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
className={editor?.isActive("orderedList") ? "bg-surface-neutral-secondary" : ""}
title={t("components.richTextEditor.toolbar.orderedList")}
disabled={disabled || !editor?.can().chain().focus().toggleOrderedList().run()}
>
<ListOrdered size={16} />
</Button>
<Button
variant="ghost"
size="xs"
type="button"
onClick={() => editor?.chain().focus().toggleCodeBlock().run()}
className={editor?.isActive("blockquote") ? "bg-surface-neutral-secondary" : ""}
title={t("components.richTextEditor.toolbar.blockquote")}
disabled={disabled || !editor?.can().chain().focus().toggleCodeBlock().run()}
>
<Quote size={16} />
</Button>
<Button
variant="ghost"
size="xs"
type="button"
onClick={() => editor?.chain().focus().unsetAllMarks().run()}
title={t("components.richTextEditor.toolbar.clearFormatting")}
disabled={disabled || !editor?.can().chain().focus().unsetAllMarks().run()}
>
<Eraser size={16} />
</Button>
{/* Highlight */}
<Button
variant="ghost"
size="xs"
type="button"
onClick={() => editor?.chain().focus().toggleHighlight().run()}
title={t("components.richTextEditor.toolbar.highlight")}
className={editor?.isActive("highlight") ? "bg-surface-neutral-secondary" : ""}
disabled={disabled || !editor?.can().chain().focus().toggleHighlight().run()}
>
<span className="bg-yellow-200 px-1 text-yellow-800">H</span>
</Button>
<Button
variant="ghost"
size="xs"
type="button"
onClick={() => editor?.chain().focus().undo().run()}
disabled={disabled || !editor?.can().chain().focus().undo().run()}
title={t("components.richTextEditor.toolbar.undo")}
>
<Undo size={16} />
</Button>
<Button
variant="ghost"
size="xs"
type="button"
onClick={() => editor?.chain().focus().redo().run()}
disabled={disabled || !editor?.can().chain().focus().redo().run()}
title={t("components.richTextEditor.toolbar.redo")}
>
<Redo size={16} />
</Button>
{/* Color Picker Button */}
<div className="relative">
{/* Hidden native color input */}
<input
type="color"
id="tiptap-color-picker"
className="absolute top-0 left-0 h-0 w-0 opacity-0"
onChange={(e) => editor?.chain().focus().setColor(e.target.value).run()}
/>
{/* Button that triggers the hidden color input */}
<Button variant="ghost" size="xs" type="button" title={t("components.richTextEditor.toolbar.textColor")} onClick={() => document.getElementById("tiptap-color-picker")?.click()}>
🎨
</Button>
</div>
</div>
);
}
type ValueDto = {
html?: string;
json?: any;
text?: string;
};
export interface InputRichTextEditorClientProps {
content: string | any | undefined;
onChange: (value: ValueDto) => void;
disabled?: boolean;
className?: string;
dirty?: boolean;
placeholder?: string;
required?: boolean;
style?: React.CSSProperties;
}
export default function InputRichTextEditorClient({ content, onChange, disabled, className, dirty, placeholder, required, style }: InputRichTextEditorClientProps) {
const rootData = useRootData();
const [hydrated, setHydrated] = useState(false);
const imageUploadOptions = useMemo(() => ({ apiBaseUrl: rootData.apiBaseUrl, tenantId: rootData.currentTenant?.id ?? null }), [rootData.apiBaseUrl, rootData.currentTenant?.id]);
const extensions: Extensions = useMemo(
() => [
...createTiptapExtensions(imageUploadOptions),
Color.configure({ types: ["textStyle"] }), // enables color
],
[imageUploadOptions]
);
const editorProps = useMemo(() => createTiptapEditorProps(imageUploadOptions), [imageUploadOptions]);
const debouncedUpdates = useCallback(
({ editor }: { editor: Editor }) => {
const json = editor.getJSON();
const html = editor.getHTML();
const text = editor.getText();
// onChange(json);
onChange({
json,
html,
text,
});
},
[onChange]
);
const editor = useEditor({
immediatelyRender: false,
extensions,
editorProps,
content,
onUpdate: (e) => {
debouncedUpdates(e);
// const json = editor?.getJSON();
// const html = editor?.getHTML();
// onChange({ json, html });
},
});
// Hydrate the editor with the content from localStorage.
useEffect(() => {
if (editor && content && !hydrated) {
editor.commands.setContent(content);
setHydrated(true);
}
}, [editor, content, hydrated]);
return (
<div
className={cn(
"border-border-default relative w-full rounded border shadow-sm",
disabled ? "bg-surface-neutral-secondary opacity-80" : "bg-surface-neutral-primary",
dirty && "bg-yellow-50 dark:bg-yellow-100 dark:text-yellow-900"
)}
>
<div className="border-b p-1">
<MenuBar editor={editor} disabled={disabled} />
</div>
{/*
Mouse-only hit-area enhancement: clicking the editor padding focuses the
TipTap editor. The editing surface (EditorContent) is itself keyboard
focusable, so this wrapper must NOT be a focusable/role-bearing element —
that would add a redundant, confusing tab stop ahead of the real editor.
*/}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
<div
className={cn("tiptap cursor-text p-4", className)}
style={style}
onClick={() => {
editor?.chain().focus().run();
}}
>
{disabled ? (
<div className="cursor-not-allowed">
<div dangerouslySetInnerHTML={{ __html: editor?.getHTML() || "" }} />
</div>
) : (
<EditorContent editor={editor} disabled={disabled} required={required} />
)}
</div>
</div>
);
}