From 6dc1aeb99bb91a51e8ef979d55785ec36ba32193 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 20 Jun 2026 12:46:18 -0400 Subject: [PATCH] feat(editor): add floating formatting toolbar --- src/App.tsx | 5 +++ src/editor/Editor.tsx | 10 ++++-- src/editor/FloatingToolbar.tsx | 59 ++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/editor/FloatingToolbar.tsx diff --git a/src/App.tsx b/src/App.tsx index c82fec8..c693b3c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,15 @@ import { useState } from "react"; +import type { Editor as TiptapEditor } from "@tiptap/react"; import { Sidebar } from "./components/Sidebar"; import { Dock } from "./components/Dock"; import { Icon } from "./components/Icon"; import { Editor } from "./editor/Editor"; +import { FloatingToolbar } from "./editor/FloatingToolbar"; import { useBook } from "./store/useBook"; function App() { const [dock, setDock] = useState(true); + const [editor, setEditor] = useState(null); const title = useBook((s) => s.book.metadata.title); const author = useBook((s) => s.book.metadata.author); const chapters = useBook((s) => s.book.chapters); @@ -49,8 +52,10 @@ function App() { chapterId={chapter.id} content={chapter.content} onChange={(content) => setChapterContent(chapter.id, content)} + onReady={setEditor} /> + {dock && } diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index 8dae98f..60d5518 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -1,4 +1,5 @@ -import { EditorContent, useEditor } from "@tiptap/react"; +import { useEffect } from "react"; +import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react"; import type { JSONContent } from "@tiptap/core"; import { editorExtensions } from "./extensions"; @@ -6,9 +7,10 @@ interface EditorProps { chapterId: string; content: JSONContent; onChange: (content: JSONContent) => void; + onReady: (editor: TiptapEditor | null) => void; } -export function Editor({ chapterId, content, onChange }: EditorProps) { +export function Editor({ chapterId, content, onChange, onReady }: EditorProps) { const editor = useEditor( { extensions: editorExtensions, @@ -20,5 +22,9 @@ export function Editor({ chapterId, content, onChange }: EditorProps) { [chapterId] ); + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + return ; } diff --git a/src/editor/FloatingToolbar.tsx b/src/editor/FloatingToolbar.tsx new file mode 100644 index 0000000..8c70d10 --- /dev/null +++ b/src/editor/FloatingToolbar.tsx @@ -0,0 +1,59 @@ +import { useRef, type ReactNode } from "react"; +import type { Editor } from "@tiptap/react"; +import { Icon } from "../components/Icon"; + +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); + }); +} + +export function FloatingToolbar({ editor }: { editor: Editor | null }) { + const fileRef = useRef(null); + 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 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(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 = ""; + }} + /> +
+ ); +}