import { useEffect, useReducer, useRef, useState, type ReactNode } from "react"; import type { Editor } from "@tiptap/react"; import { Icon } from "../components/Icon"; import type { Alignment } from "./align"; const ALIGN_ICONS: Record = { left: "M4 7h16M4 12h10M4 17h13", center: "M4 7h16M7 12h10M5 17h14", right: "M4 7h16M10 12h10M7 17h13", }; function readImage(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(file); }); } function normalizeUrl(url: string): string { const trimmed = url.trim(); if (!trimmed) return ""; if (/^(https?:\/\/|mailto:|tel:|#|\/)/i.test(trimmed)) return trimmed; return `https://${trimmed}`; } export function FloatingToolbar({ editor }: { editor: Editor | null }) { const fileRef = useRef(null); const linkInputRef = useRef(null); const [linkOpen, setLinkOpen] = useState(false); const [linkValue, setLinkValue] = useState(""); const [alignOpen, setAlignOpen] = useState(false); const [, forceUpdate] = useReducer((x) => x + 1, 0); useEffect(() => { if (linkOpen) linkInputRef.current?.focus(); }, [linkOpen]); useEffect(() => { if (!editor) return; const update = () => forceUpdate(); editor.on("transaction", update); return () => { editor.off("transaction", update); }; }, [editor]); if (!editor) return null; const insertImage = async (file: File) => { const src = await readImage(file); editor.chain().focus().insertContent({ type: "figure", attrs: { src, placement: "full-width" } }).run(); }; const openLink = () => { setLinkValue((editor.getAttributes("link").href as string) ?? ""); setLinkOpen(true); }; const applyLink = () => { const href = normalizeUrl(linkValue); if (!href) { editor.chain().focus().extendMarkRange("link").unsetLink().run(); } else if (editor.state.selection.empty && !editor.isActive("link")) { editor.chain().focus().insertContent({ type: "text", text: href, marks: [{ type: "link", attrs: { href } }] }).run(); } else { editor.chain().focus().extendMarkRange("link").setLink({ href }).run(); } setLinkOpen(false); }; const removeLink = () => { editor.chain().focus().extendMarkRange("link").unsetLink().run(); setLinkOpen(false); }; const align: Alignment = (editor.getAttributes("heading").align ?? editor.getAttributes("paragraph").align) || "left"; const setAlign = (value: Alignment) => { editor.chain().focus().setTextAlign(value).run(); setAlignOpen(false); }; const tool = (active: boolean, onClick: () => void, title: string, content: ReactNode) => ( ); return (
{tool(editor.isActive("bold"), () => editor.chain().focus().toggleBold().run(), "Bold", B)} {tool(editor.isActive("italic"), () => editor.chain().focus().toggleItalic().run(), "Italic", I)} {tool(editor.isActive("heading", { level: 2 }), () => editor.chain().focus().toggleHeading({ level: 2 }).run(), "Heading", )} {tool(editor.isActive("blockquote"), () => editor.chain().focus().toggleBlockquote().run(), "Quote", )} {tool(editor.isActive("bulletList"), () => editor.chain().focus().toggleBulletList().run(), "Bulleted list", )} {tool(alignOpen || align !== "left", () => setAlignOpen((v) => !v), "Align", )} {alignOpen && ( <>
setAlignOpen(false)} />
e.stopPropagation()}> {(["left", "center", "right"] as Alignment[]).map((value) => ( ))}
)} {tool(editor.isActive("link") || linkOpen, () => (linkOpen ? setLinkOpen(false) : openLink()), "Link", )} {linkOpen && ( <>
setLinkOpen(false)} />
e.stopPropagation()}> setLinkValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyLink(); } else if (e.key === "Escape") { e.preventDefault(); setLinkOpen(false); } }} /> {editor.isActive("link") && ( )}
)} {tool(false, () => editor.chain().focus().setHorizontalRule().run(), "Scene break", )} {tool(false, () => fileRef.current?.click(), "Insert image", )} { const file = e.target.files?.[0]; if (file) insertImage(file); e.currentTarget.value = ""; }} />
); }