mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
feat(io): save and open .margin projects with cmd-s and cmd-o
This commit is contained in:
1 parent
13f2a70af2
commit
aedf8b8a63
4 files changed
+78
-1
No files matched your search
+41
-1
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { Editor as TiptapEditor } from "@tiptap/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";
|
||||||
@@ -6,6 +6,7 @@ import { Icon } from "./components/Icon";
|
|||||||
import { Editor } from "./editor/Editor";
|
import { Editor } from "./editor/Editor";
|
||||||
import { FloatingToolbar } from "./editor/FloatingToolbar";
|
import { FloatingToolbar } from "./editor/FloatingToolbar";
|
||||||
import { useBook } from "./store/useBook";
|
import { useBook } from "./store/useBook";
|
||||||
|
import { chooseSavePath, openBook, writeBook } from "./project";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [dock, setDock] = useState(true);
|
const [dock, setDock] = useState(true);
|
||||||
@@ -14,20 +15,59 @@ function App() {
|
|||||||
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);
|
||||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||||
|
const dirty = useBook((s) => s.dirty);
|
||||||
const setChapterContent = useBook((s) => s.setChapterContent);
|
const setChapterContent = useBook((s) => s.setChapterContent);
|
||||||
const setChapterTitle = useBook((s) => s.setChapterTitle);
|
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 idx = chapters.findIndex((c) => c.id === activeChapterId);
|
||||||
const chapter = chapters[idx];
|
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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header className="titlebar" data-tauri-drag-region>
|
<header className="titlebar" data-tauri-drag-region>
|
||||||
<span className="doc-title">
|
<span className="doc-title">
|
||||||
{title || "Untitled"}
|
{title || "Untitled"}
|
||||||
{author ? ` — ${author}` : ""}
|
{author ? ` — ${author}` : ""}
|
||||||
|
{dirty && <span className="dirty-dot" />}
|
||||||
</span>
|
</span>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
|
<button className="icon-btn" onClick={handleOpen} title="Open (⌘O)">
|
||||||
|
<Icon d="M3 7l1.7-2.5h4.6L11 7h8a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z" />
|
||||||
|
</button>
|
||||||
|
<button className="icon-btn" onClick={handleSave} title="Save (⌘S)">
|
||||||
|
<Icon d="M12 4v10m0 0l-3.5-3.5M12 14l3.5-3.5M5 19h14" />
|
||||||
|
</button>
|
||||||
<button className="icon-btn" data-on={dock} onClick={() => setDock(!dock)} title="Toggle preview">
|
<button className="icon-btn" data-on={dock} onClick={() => setDock(!dock)} title="Toggle preview">
|
||||||
<Icon d="M3 4.5h18v15H3zM14 4.5v15" />
|
<Icon d="M3 4.5h18v15H3zM14 4.5v15" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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<string | null> {
|
||||||
|
return save({ filters: FILTERS, defaultPath: `${book.metadata.title || "Untitled"}.margin` });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeBook(book: Book, path: string): Promise<void> {
|
||||||
|
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<string>("read_file", { path: selected });
|
||||||
|
return { book: JSON.parse(contents) as Book, path: selected };
|
||||||
|
}
|
||||||
@@ -5,11 +5,14 @@ import { type Book, createChapter, starterBook } from "../model/book";
|
|||||||
interface BookState {
|
interface BookState {
|
||||||
book: Book;
|
book: Book;
|
||||||
activeChapterId: string;
|
activeChapterId: string;
|
||||||
|
path: string | null;
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
setActiveChapter: (id: string) => void;
|
setActiveChapter: (id: string) => void;
|
||||||
setChapterContent: (id: string, content: JSONContent) => void;
|
setChapterContent: (id: string, content: JSONContent) => void;
|
||||||
setChapterTitle: (id: string, title: string) => void;
|
setChapterTitle: (id: string, title: string) => void;
|
||||||
addChapter: () => void;
|
addChapter: () => void;
|
||||||
|
replaceBook: (book: Book, path: string) => void;
|
||||||
|
markSaved: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBook = create<BookState>((set) => {
|
export const useBook = create<BookState>((set) => {
|
||||||
@@ -17,6 +20,7 @@ export const useBook = create<BookState>((set) => {
|
|||||||
return {
|
return {
|
||||||
book,
|
book,
|
||||||
activeChapterId: book.chapters[0].id,
|
activeChapterId: book.chapters[0].id,
|
||||||
|
path: null,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
setActiveChapter: (id) => set({ activeChapterId: id }),
|
setActiveChapter: (id) => set({ activeChapterId: id }),
|
||||||
setChapterContent: (id, content) =>
|
setChapterContent: (id, content) =>
|
||||||
@@ -44,5 +48,8 @@ export const useBook = create<BookState>((set) => {
|
|||||||
book: { ...s.book, chapters: [...s.book.chapters, chapter] },
|
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 }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -82,6 +82,16 @@ button {
|
|||||||
color: var(--accent);
|
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 {
|
.body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in new issue
Block a user