feat(editor): add floating formatting toolbar

This commit is contained in:
pj committed 2026-06-20 12:46:18 -04:00
1 parent 50bde45d65
commit 6dc1aeb99b
3 files changed
+72 -2

No files matched your search

+5
View File
@@ -1,12 +1,15 @@
import { useState } from "react"; import { useState } from "react";
import type { Editor as TiptapEditor } from "@tiptap/react";
import { Sidebar } from "./components/Sidebar"; import { Sidebar } from "./components/Sidebar";
import { Dock } from "./components/Dock"; import { Dock } from "./components/Dock";
import { Icon } from "./components/Icon"; import { Icon } from "./components/Icon";
import { Editor } from "./editor/Editor"; import { Editor } from "./editor/Editor";
import { FloatingToolbar } from "./editor/FloatingToolbar";
import { useBook } from "./store/useBook"; import { useBook } from "./store/useBook";
function App() { function App() {
const [dock, setDock] = useState(true); const [dock, setDock] = useState(true);
const [editor, setEditor] = useState<TiptapEditor | null>(null);
const title = useBook((s) => s.book.metadata.title); const title = useBook((s) => s.book.metadata.title);
const author = useBook((s) => s.book.metadata.author); const author = useBook((s) => s.book.metadata.author);
const chapters = useBook((s) => s.book.chapters); const chapters = useBook((s) => s.book.chapters);
@@ -49,8 +52,10 @@ function App() {
chapterId={chapter.id} chapterId={chapter.id}
content={chapter.content} content={chapter.content}
onChange={(content) => setChapterContent(chapter.id, content)} onChange={(content) => setChapterContent(chapter.id, content)}
onReady={setEditor}
/> />
</article> </article>
<FloatingToolbar editor={editor} />
</main> </main>
{dock && <Dock />} {dock && <Dock />}
</div> </div>
+8 -2
View File
@@ -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 type { JSONContent } from "@tiptap/core";
import { editorExtensions } from "./extensions"; import { editorExtensions } from "./extensions";
@@ -6,9 +7,10 @@ interface EditorProps {
chapterId: string; chapterId: string;
content: JSONContent; content: JSONContent;
onChange: (content: JSONContent) => void; 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( const editor = useEditor(
{ {
extensions: editorExtensions, extensions: editorExtensions,
@@ -20,5 +22,9 @@ export function Editor({ chapterId, content, onChange }: EditorProps) {
[chapterId] [chapterId]
); );
useEffect(() => {
onReady(editor);
}, [editor, onReady]);
return <EditorContent editor={editor} className="editor-host" />; return <EditorContent editor={editor} className="editor-host" />;
} }
+59
View File
@@ -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<string> {
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<HTMLInputElement>(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) => (
<button
className="tool"
data-on={active}
title={title}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
>
{content}
</button>
);
return (
<div className="editor-toolbar">
{tool(editor.isActive("bold"), () => editor.chain().focus().toggleBold().run(), "Bold", <b>B</b>)}
{tool(editor.isActive("italic"), () => editor.chain().focus().toggleItalic().run(), "Italic", <i>I</i>)}
<span className="tool-sep" />
{tool(editor.isActive("heading", { level: 2 }), () => editor.chain().focus().toggleHeading({ level: 2 }).run(), "Heading", <Icon d="M5 5v14M5 12h8M13 5v14" />)}
{tool(editor.isActive("blockquote"), () => editor.chain().focus().toggleBlockquote().run(), "Quote", <Icon d="M7 8h4v4a4 4 0 0 1-4 4M14 8h4v4a4 4 0 0 1-4 4" />)}
{tool(editor.isActive("bulletList"), () => editor.chain().focus().toggleBulletList().run(), "Bulleted list", <Icon d="M8 6h12M8 12h12M8 18h12M3.5 6h.01M3.5 12h.01M3.5 18h.01" />)}
<span className="tool-sep" />
{tool(false, () => editor.chain().focus().setHorizontalRule().run(), "Scene break", <Icon d="M5 12h5M14 12h5" />)}
{tool(false, () => fileRef.current?.click(), "Insert image", <Icon d="M4 5h16v14H4zM4 16l4.5-4.5 3 3L16 10l4 4" />)}
<input
ref={fileRef}
type="file"
accept="image/*"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) insertImage(file);
e.currentTarget.value = "";
}}
/>
</div>
);
}