320 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|