diff --git a/public/margin-logo.png b/public/margin-logo.png index 92e361d..b5cc81f 100644 Binary files a/public/margin-logo.png and b/public/margin-logo.png differ diff --git a/public/margin-mark.png b/public/margin-mark.png index 082c074..b5cc81f 100644 Binary files a/public/margin-mark.png and b/public/margin-mark.png differ diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 05f65ed..3477df4 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 06c71de..49c3836 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 700b093..cc8dd4f 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index 0c8becd..f67c7ba 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index a4df770..08a8253 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 8a5b19d..f5fbe7e 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index d41ad8c..8a44782 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 6be7aa1..44ac119 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 2cffe64..a2377a2 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index ca3d443..ecc9801 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index fd2367d..0e07b15 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 1e791ce..349f1c4 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index 52b01d3..780ff1c 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 542fc6f..18eb899 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 94b91cc..eac790d 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index f39c289..8d945ad 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index b0515e8..400403d 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/epub.rs b/src-tauri/src/epub.rs index ed2da57..3260a26 100644 --- a/src-tauri/src/epub.rs +++ b/src-tauri/src/epub.rs @@ -59,7 +59,7 @@ fn build(files: &[EpubFile]) -> Result, String> { Ok(zip.finish().map_err(|e| e.to_string())?.into_inner()) } -#[tauri::command] +#[tauri::command(async)] pub fn package_epub(files: Vec) -> Result { build(&files).map(tauri::ipc::Response::new) } diff --git a/src-tauri/src/pdf.rs b/src-tauri/src/pdf.rs index 9df94fb..351a717 100644 --- a/src-tauri/src/pdf.rs +++ b/src-tauri/src/pdf.rs @@ -36,7 +36,7 @@ fn compile(source: String, images: &[ImageInput]) -> Result, String> { typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d)) } -#[tauri::command] +#[tauri::command(async)] pub fn compile_pdf(source: String, images: Vec) -> Result { compile(source, &images).map(tauri::ipc::Response::new) } diff --git a/src/App.tsx b/src/App.tsx index f765f64..dbd45df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import { EditorView } from "./components/EditorView"; import { useBook } from "./store/useBook"; import { isDesktop } from "./ipc"; import { newBook } from "./library"; -import { exportEpub, exportPdf } from "./export/exporters"; +import { runExport } from "./export/run"; function App() { const book = useBook((s) => s.book); @@ -16,8 +16,8 @@ function App() { const unlisten = listen("menu-action", (event) => { const state = useBook.getState(); if (event.payload === "new-book") state.openBook(newBook()); - else if (event.payload === "export-pdf" && state.book) exportPdf(state.book); - else if (event.payload === "export-epub" && state.book) exportEpub(state.book); + else if (event.payload === "export-pdf") runExport("pdf"); + else if (event.payload === "export-epub") runExport("epub"); }); return () => { unlisten.then((stop) => stop()); diff --git a/src/components/EditorView.tsx b/src/components/EditorView.tsx index 5af5091..59b7dac 100644 --- a/src/components/EditorView.tsx +++ b/src/components/EditorView.tsx @@ -9,18 +9,18 @@ import { Editor } from "../editor/Editor"; import { FloatingToolbar } from "../editor/FloatingToolbar"; import { COVER_ID, useBook } from "../store/useBook"; import { useTheme } from "../store/useTheme"; -import { type Book, bodyNumber, chapterKind } from "../model/book"; +import { bodyNumber, chapterKind } from "../model/book"; import { saveBook } from "../library"; import { isDesktop } from "../ipc"; -import { exportEpub, exportPdf } from "../export/exporters"; - -const DESKTOP_ONLY = - 'Export runs in the desktop app only — open the window from "pnpm tauri dev" (you are viewing the browser preview).'; +import { runExport } from "../export/run"; export function EditorView() { const book = useBook((s) => s.book); const activeChapterId = useBook((s) => s.activeChapterId); const dirty = useBook((s) => s.dirty); + const exporting = useBook((s) => s.exporting); + const notice = useBook((s) => s.notice); + const setNotice = useBook((s) => s.setNotice); const setChapterContent = useBook((s) => s.setChapterContent); const setChapterTitle = useBook((s) => s.setChapterTitle); const markSaved = useBook((s) => s.markSaved); @@ -31,7 +31,6 @@ export function EditorView() { const [dock, setDock] = useState(true); const [settingsOpen, setSettingsOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false); - const [notice, setNotice] = useState(null); const saveNow = useCallback(() => { const current = useBook.getState().book; @@ -61,19 +60,9 @@ export function EditorView() { return () => clearTimeout(timer); }, [notice]); - const runExport = async (label: string, fn: (book: Book) => Promise) => { + const handleExport = (format: "pdf" | "epub") => { setExportOpen(false); - if (!isDesktop) { - setNotice(DESKTOP_ONLY); - return; - } - const current = useBook.getState().book; - if (!current) return; - try { - await fn(current); - } catch (e) { - setNotice(`${label} export failed: ${e}`); - } + runExport(format); }; if (!book) return null; @@ -101,8 +90,8 @@ export function EditorView() { <>
setExportOpen(false)} />
- - + +
)} @@ -156,6 +145,12 @@ export function EditorView() {
{settingsOpen && setSettingsOpen(false)} onSave={saveNow} />} + {exporting && ( +
+
+

Exporting {exporting}…

+
+ )} {notice && (
setNotice(null)}> {notice} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 8985fe2..6baee7e 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { COVER_ID, useBook } from "../store/useBook"; import { type Chapter, type ChapterKind, bodyNumber, chapterKind } from "../model/book"; import { AddPageMenu } from "./AddPageMenu"; @@ -12,6 +12,11 @@ interface Row { num: number | null; } +interface DropTarget { + kind: ChapterKind; + index: number; +} + function formatEdited(ts: number, now: number): string { const diff = Math.max(0, now - ts); const min = 60_000; @@ -42,11 +47,15 @@ export function Sidebar() { const moveChapter = useBook((s) => s.moveChapter); const closeBook = useBook((s) => s.closeBook); - const [drag, setDrag] = useState<{ kind: ChapterKind; index: number } | null>(null); - const [dropIndex, setDropIndex] = useState(null); + const [dragIndex, setDragIndex] = useState(null); + const [dropTarget, setDropTarget] = useState(null); const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null); const [now, setNow] = useState(() => Date.now()); + const dropRef = useRef(null); + const gesture = useRef<{ from: number; x: number; y: number; active: boolean } | null>(null); + const suppressClick = useRef(false); + useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 60_000); return () => clearInterval(timer); @@ -58,27 +67,76 @@ export function Sidebar() { { kind: "body", label: "Chapters", rows: rows.filter((r) => chapterKind(r.chapter) === "body") }, { kind: "back", label: "Back matter", rows: rows.filter((r) => chapterKind(r.chapter) === "back") }, ]; - - const onDragOver = (e: React.DragEvent, kind: ChapterKind, index: number) => { - if (!drag || drag.kind !== kind) return; - e.preventDefault(); - const rect = e.currentTarget.getBoundingClientRect(); - const after = e.clientY > rect.top + rect.height / 2; - setDropIndex(after ? index + 1 : index); + const groupEnd: Record = { + front: groups[0].rows.length, + body: groups[0].rows.length + groups[1].rows.length, + back: rows.length, }; - const onDrop = () => { - if (drag && dropIndex !== null) { - const to = drag.index < dropIndex ? dropIndex - 1 : dropIndex; - moveChapter(drag.index, to); + const dragging = dragIndex !== null; + + const setDrop = (t: DropTarget | null) => { + dropRef.current = t; + setDropTarget(t); + }; + + const computeDrop = (x: number, y: number): DropTarget | null => { + const el = document.elementFromPoint(x, y) as HTMLElement | null; + const row = el?.closest(".chapter"); + if (row?.dataset.idx) { + const rect = row.getBoundingClientRect(); + const idx = Number(row.dataset.idx); + const after = y > rect.top + rect.height / 2; + return { kind: row.dataset.kind as ChapterKind, index: after ? idx + 1 : idx }; } - setDrag(null); - setDropIndex(null); + const section = el?.closest(".nav-section"); + if (section?.dataset.kind) { + const kind = section.dataset.kind as ChapterKind; + return { kind, index: groupEnd[kind] }; + } + return null; }; - const reset = () => { - setDrag(null); - setDropIndex(null); + const onPointerMove = (e: PointerEvent) => { + const g = gesture.current; + if (!g) return; + if (!g.active) { + if (Math.abs(e.clientX - g.x) < 4 && Math.abs(e.clientY - g.y) < 4) return; + g.active = true; + setDragIndex(g.from); + } + const t = computeDrop(e.clientX, e.clientY); + if (t) setDrop(t); + }; + + const onPointerUp = () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + const g = gesture.current; + gesture.current = null; + if (g?.active) { + suppressClick.current = true; + const t = dropRef.current; + if (t) moveChapter(g.from, t.index, t.kind); + } + setDragIndex(null); + setDrop(null); + }; + + const onRowPointerDown = (e: React.PointerEvent, index: number) => { + if (e.button !== 0 || (e.target as HTMLElement).closest(".row-menu-btn")) return; + suppressClick.current = false; + gesture.current = { from: index, x: e.clientX, y: e.clientY, active: false }; + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + }; + + const onRowClick = (id: string) => { + if (suppressClick.current) { + suppressClick.current = false; + return; + } + setActiveChapter(id); }; return ( @@ -99,24 +157,22 @@ export function Sidebar() {
{groups.map((group) => - group.kind === "body" || group.rows.length ? ( -
+ group.kind === "body" || group.rows.length || dragging ? ( +
{group.label}
    {group.rows.map((row, i) => (
  • setActiveChapter(row.chapter.id)} - onDragStart={() => setDrag({ kind: group.kind, index: row.index })} - onDragOver={(e) => onDragOver(e, group.kind, row.index)} - onDrop={onDrop} - onDragEnd={reset} + data-dragging={dragIndex === row.index} + data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index} + data-drop-after={dropTarget?.kind === group.kind && i === group.rows.length - 1 && dropTarget.index === row.index + 1} + onClick={() => onRowClick(row.chapter.id)} + onPointerDown={(e) => onRowPointerDown(e, row.index)} > @@ -134,6 +190,11 @@ export function Sidebar() { />
  • ))} + {!group.rows.length && ( +
  • + Drop here +
  • + )}
{group.kind === "body" && (