diff --git a/src/App.tsx b/src/App.tsx index c693b3c..74437b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { Editor as TiptapEditor } from "@tiptap/react"; import { Sidebar } from "./components/Sidebar"; import { Dock } from "./components/Dock"; @@ -6,6 +6,7 @@ import { Icon } from "./components/Icon"; import { Editor } from "./editor/Editor"; import { FloatingToolbar } from "./editor/FloatingToolbar"; import { useBook } from "./store/useBook"; +import { chooseSavePath, openBook, writeBook } from "./project"; function App() { const [dock, setDock] = useState(true); @@ -14,20 +15,59 @@ function App() { const author = useBook((s) => s.book.metadata.author); const chapters = useBook((s) => s.book.chapters); const activeChapterId = useBook((s) => s.activeChapterId); + const dirty = useBook((s) => s.dirty); const setChapterContent = useBook((s) => s.setChapterContent); const setChapterTitle = useBook((s) => s.setChapterTitle); + const replaceBook = useBook((s) => s.replaceBook); + const markSaved = useBook((s) => s.markSaved); const idx = chapters.findIndex((c) => c.id === activeChapterId); const chapter = chapters[idx]; + const handleSave = useCallback(async () => { + const { book, path } = useBook.getState(); + const target = path ?? (await chooseSavePath(book)); + if (!target) return; + await writeBook(book, target); + markSaved(target); + }, [markSaved]); + + const handleOpen = useCallback(async () => { + const result = await openBook(); + if (result) replaceBook(result.book, result.path); + }, [replaceBook]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey; + if (mod && e.key.toLowerCase() === "s") { + e.preventDefault(); + handleSave(); + } + if (mod && e.key.toLowerCase() === "o") { + e.preventDefault(); + handleOpen(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [handleSave, handleOpen]); + return (
{title || "Untitled"} {author ? ` — ${author}` : ""} + {dirty && }
+ + diff --git a/src/project.ts b/src/project.ts new file mode 100644 index 0000000..54c8353 --- /dev/null +++ b/src/project.ts @@ -0,0 +1,20 @@ +import { save, open } from "@tauri-apps/plugin-dialog"; +import { invoke } from "@tauri-apps/api/core"; +import type { Book } from "./model/book"; + +const FILTERS = [{ name: "margin book", extensions: ["margin"] }]; + +export async function chooseSavePath(book: Book): Promise { + return save({ filters: FILTERS, defaultPath: `${book.metadata.title || "Untitled"}.margin` }); +} + +export async function writeBook(book: Book, path: string): Promise { + await invoke("write_file", { path, contents: JSON.stringify(book, null, 2) }); +} + +export async function openBook(): Promise<{ book: Book; path: string } | null> { + const selected = await open({ filters: FILTERS, multiple: false, directory: false }); + if (typeof selected !== "string") return null; + const contents = await invoke("read_file", { path: selected }); + return { book: JSON.parse(contents) as Book, path: selected }; +} diff --git a/src/store/useBook.ts b/src/store/useBook.ts index 033b75e..a8fbf2b 100644 --- a/src/store/useBook.ts +++ b/src/store/useBook.ts @@ -5,11 +5,14 @@ import { type Book, createChapter, starterBook } from "../model/book"; interface BookState { book: Book; activeChapterId: string; + path: string | null; dirty: boolean; setActiveChapter: (id: string) => void; setChapterContent: (id: string, content: JSONContent) => void; setChapterTitle: (id: string, title: string) => void; addChapter: () => void; + replaceBook: (book: Book, path: string) => void; + markSaved: (path: string) => void; } export const useBook = create((set) => { @@ -17,6 +20,7 @@ export const useBook = create((set) => { return { book, activeChapterId: book.chapters[0].id, + path: null, dirty: false, setActiveChapter: (id) => set({ activeChapterId: id }), setChapterContent: (id, content) => @@ -44,5 +48,8 @@ export const useBook = create((set) => { book: { ...s.book, chapters: [...s.book.chapters, chapter] }, }; }), + replaceBook: (book, path) => + set({ book, activeChapterId: book.chapters[0]?.id ?? "", path, dirty: false }), + markSaved: (path) => set({ path, dirty: false }), }; }); diff --git a/src/styles/app.css b/src/styles/app.css index 0a5bad2..9b82d16 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -82,6 +82,16 @@ button { color: var(--accent); } +.dirty-dot { + display: inline-block; + width: 5px; + height: 5px; + margin-left: 8px; + border-radius: 50%; + background: var(--accent); + vertical-align: middle; +} + .body { flex: 1; display: flex;