208 lines
6.5 KiB
TypeScript
208 lines
6.5 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { type ChangeEvent, useEffect, useRef, useState } from "react";
|
||
|
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||
|
|
import StarterKit from "@tiptap/starter-kit";
|
||
|
|
import Underline from "@tiptap/extension-underline";
|
||
|
|
import Link from "@tiptap/extension-link";
|
||
|
|
import Placeholder from "@tiptap/extension-placeholder";
|
||
|
|
import Image from "@tiptap/extension-image";
|
||
|
|
|
||
|
|
type TiptapHtmlEditorProps = {
|
||
|
|
value: string;
|
||
|
|
onChange: (html: string) => void;
|
||
|
|
placeholder?: string;
|
||
|
|
onUploadImage?: (file: File) => Promise<string>;
|
||
|
|
};
|
||
|
|
|
||
|
|
type ToolbarButtonProps = {
|
||
|
|
active?: boolean;
|
||
|
|
disabled?: boolean;
|
||
|
|
label: string;
|
||
|
|
onClick: () => void;
|
||
|
|
};
|
||
|
|
|
||
|
|
function ToolbarButton({ active = false, disabled = false, label, onClick }: ToolbarButtonProps) {
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={onClick}
|
||
|
|
disabled={disabled}
|
||
|
|
className={`rounded-full border px-3 py-2 text-[11px] font-black uppercase tracking-[0.14em] transition ${
|
||
|
|
active
|
||
|
|
? "border-[#FF5722] bg-[#FFF4EF] text-[#112015]"
|
||
|
|
: "border-[#112015]/10 bg-white text-[#536256] hover:border-[#8BC34A] hover:text-[#112015]"
|
||
|
|
} ${disabled ? "cursor-not-allowed opacity-50 hover:border-[#112015]/10 hover:text-[#536256]" : ""}`}
|
||
|
|
>
|
||
|
|
{label}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function TiptapHtmlEditor({
|
||
|
|
value,
|
||
|
|
onChange,
|
||
|
|
placeholder = "Skriv artikkelen her...",
|
||
|
|
onUploadImage,
|
||
|
|
}: TiptapHtmlEditorProps) {
|
||
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||
|
|
const [isUploading, setIsUploading] = useState(false);
|
||
|
|
const editor = useEditor({
|
||
|
|
extensions: [
|
||
|
|
StarterKit.configure({
|
||
|
|
heading: {
|
||
|
|
levels: [2, 3],
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
Underline,
|
||
|
|
Link.configure({
|
||
|
|
autolink: true,
|
||
|
|
openOnClick: false,
|
||
|
|
protocols: ["http", "https", "mailto"],
|
||
|
|
}),
|
||
|
|
Image,
|
||
|
|
Placeholder.configure({
|
||
|
|
placeholder,
|
||
|
|
}),
|
||
|
|
],
|
||
|
|
content: value || "<p></p>",
|
||
|
|
immediatelyRender: false,
|
||
|
|
editorProps: {
|
||
|
|
attributes: {
|
||
|
|
class:
|
||
|
|
"min-h-[28rem] rounded-[1.5rem] border border-[#112015]/10 bg-white px-5 py-5 text-base leading-8 text-[#112015] outline-none focus:border-[#8BC34A]",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
onUpdate: ({ editor: currentEditor }) => {
|
||
|
|
onChange(currentEditor.getHTML());
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!editor) return;
|
||
|
|
const currentHtml = editor.getHTML();
|
||
|
|
if (value !== currentHtml) {
|
||
|
|
editor.commands.setContent(value || "<p></p>", { emitUpdate: false });
|
||
|
|
}
|
||
|
|
}, [editor, value]);
|
||
|
|
|
||
|
|
if (!editor) {
|
||
|
|
return (
|
||
|
|
<div className="rounded-[1.5rem] border border-[#112015]/10 bg-white px-5 py-5 text-sm font-bold text-[#536256]">
|
||
|
|
Laster editor...
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const setLink = () => {
|
||
|
|
const previousUrl = editor.getAttributes("link").href as string | undefined;
|
||
|
|
const url = window.prompt("Lenke-URL", previousUrl || "https://");
|
||
|
|
if (url === null) return;
|
||
|
|
if (url.trim() === "") {
|
||
|
|
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
editor.chain().focus().extendMarkRange("link").setLink({ href: url.trim() }).run();
|
||
|
|
};
|
||
|
|
|
||
|
|
const insertImage = () => {
|
||
|
|
const url = window.prompt("Bilde-URL", "https://");
|
||
|
|
if (!url || !url.trim()) return;
|
||
|
|
editor.chain().focus().setImage({ src: url.trim() }).run();
|
||
|
|
};
|
||
|
|
|
||
|
|
const triggerImageUpload = () => {
|
||
|
|
fileInputRef.current?.click();
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||
|
|
const file = event.target.files?.[0];
|
||
|
|
event.target.value = "";
|
||
|
|
|
||
|
|
if (!file || !onUploadImage) return;
|
||
|
|
|
||
|
|
setIsUploading(true);
|
||
|
|
try {
|
||
|
|
const url = await onUploadImage(file);
|
||
|
|
editor.chain().focus().setImage({ src: url, alt: file.name }).run();
|
||
|
|
} catch (error) {
|
||
|
|
window.alert(error instanceof Error ? error.message : "Kunne ikke laste opp bildet.");
|
||
|
|
} finally {
|
||
|
|
setIsUploading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<input
|
||
|
|
ref={fileInputRef}
|
||
|
|
type="file"
|
||
|
|
accept="image/*"
|
||
|
|
className="hidden"
|
||
|
|
onChange={handleImageUpload}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<div className="flex flex-wrap gap-2 rounded-[1.5rem] border border-[#112015]/8 bg-[#F7F9F2] p-3">
|
||
|
|
<ToolbarButton label="Brødtekst" onClick={() => editor.chain().focus().setParagraph().run()} />
|
||
|
|
<ToolbarButton
|
||
|
|
label="H2"
|
||
|
|
active={editor.isActive("heading", { level: 2 })}
|
||
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||
|
|
/>
|
||
|
|
<ToolbarButton
|
||
|
|
label="H3"
|
||
|
|
active={editor.isActive("heading", { level: 3 })}
|
||
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||
|
|
/>
|
||
|
|
<ToolbarButton
|
||
|
|
label="Bold"
|
||
|
|
active={editor.isActive("bold")}
|
||
|
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||
|
|
/>
|
||
|
|
<ToolbarButton
|
||
|
|
label="Italic"
|
||
|
|
active={editor.isActive("italic")}
|
||
|
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||
|
|
/>
|
||
|
|
<ToolbarButton
|
||
|
|
label="Underline"
|
||
|
|
active={editor.isActive("underline")}
|
||
|
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||
|
|
/>
|
||
|
|
<ToolbarButton
|
||
|
|
label="Punktliste"
|
||
|
|
active={editor.isActive("bulletList")}
|
||
|
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||
|
|
/>
|
||
|
|
<ToolbarButton
|
||
|
|
label="Nummerert"
|
||
|
|
active={editor.isActive("orderedList")}
|
||
|
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||
|
|
/>
|
||
|
|
<ToolbarButton
|
||
|
|
label="Sitat"
|
||
|
|
active={editor.isActive("blockquote")}
|
||
|
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||
|
|
/>
|
||
|
|
<ToolbarButton
|
||
|
|
label="Lenke"
|
||
|
|
active={editor.isActive("link")}
|
||
|
|
onClick={setLink}
|
||
|
|
/>
|
||
|
|
<ToolbarButton
|
||
|
|
label={isUploading ? "Laster opp..." : "Last opp bilde"}
|
||
|
|
onClick={triggerImageUpload}
|
||
|
|
disabled={isUploading || !onUploadImage}
|
||
|
|
/>
|
||
|
|
<ToolbarButton label="Bilde-URL" onClick={insertImage} />
|
||
|
|
<ToolbarButton label="Angre" onClick={() => editor.chain().focus().undo().run()} />
|
||
|
|
<ToolbarButton label="Gjør om" onClick={() => editor.chain().focus().redo().run()} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="rounded-[1.75rem] border border-[#112015]/8 bg-[#FCFDF9] p-3 sm:p-4">
|
||
|
|
<EditorContent editor={editor} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|