mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
changes
This commit is contained in:
1 parent
734ded73ac
commit
c86d5a7d5c
14 files changed
+671
-86
No files matched your search
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PAGE_TYPES } from "../model/book";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface AddPageMenuProps {
|
||||
onAdd: (group: "front" | "back", title: string) => void;
|
||||
}
|
||||
|
||||
const GROUPS: { key: "front" | "back"; label: string }[] = [
|
||||
{ key: "front", label: "Front matter" },
|
||||
{ key: "back", label: "Back matter" },
|
||||
];
|
||||
|
||||
export function AddPageMenu({ onAdd }: AddPageMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const toggle = () => {
|
||||
if (!open && btnRef.current) {
|
||||
const r = btnRef.current.getBoundingClientRect();
|
||||
setCoords({ top: r.bottom + 4, left: r.left });
|
||||
}
|
||||
setOpen((v) => !v);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = () => setOpen(false);
|
||||
document.addEventListener("mousedown", close);
|
||||
document.addEventListener("scroll", close, true);
|
||||
window.addEventListener("resize", close);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", close);
|
||||
document.removeEventListener("scroll", close, true);
|
||||
window.removeEventListener("resize", close);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const pick = (group: "front" | "back", label: string) => {
|
||||
setOpen(false);
|
||||
onAdd(group, label);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={btnRef} className="add-page" data-open={open} onClick={toggle}>
|
||||
<Icon d="M12 5v14M5 12h14" size={15} />
|
||||
Add page
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="add-page-pop"
|
||||
style={{ top: coords.top, left: coords.left }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{GROUPS.map((g) => (
|
||||
<div key={g.key} className="add-page-group">
|
||||
<div className="add-page-label">{g.label}</div>
|
||||
{PAGE_TYPES.filter((t) => t.group === g.key).map((t) => (
|
||||
<button key={t.id} className="add-page-item" onClick={() => pick(t.group, t.label)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
confirmLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) {
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="panel-head">
|
||||
<h2>{title}</h2>
|
||||
<button className="icon-btn" onClick={onClose} title="Close">
|
||||
<Icon d="M6 6l12 12M18 6L6 18" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p className="confirm-text">{message}</p>
|
||||
</div>
|
||||
<div className="panel-foot">
|
||||
<button className="btn-ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn-danger" onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { generateHTML } from "@tiptap/core";
|
||||
import { COVER_ID, useBook } from "../store/useBook";
|
||||
import type { TrimSize } from "../model/book";
|
||||
import { type TrimSize, bodyNumber, chapterKind } from "../model/book";
|
||||
import { editorExtensions } from "../editor/extensions";
|
||||
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
||||
import { compilePdf, isDesktop } from "../ipc";
|
||||
@@ -115,6 +115,10 @@ function HtmlDock() {
|
||||
|
||||
if (!chapter) return null;
|
||||
|
||||
const kind = chapterKind(chapter);
|
||||
const eyebrow =
|
||||
kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
||||
|
||||
return (
|
||||
<section className="dock">
|
||||
<div className="dock-head">
|
||||
@@ -128,7 +132,7 @@ function HtmlDock() {
|
||||
</div>
|
||||
<div className="page">
|
||||
<div className="p-opener">
|
||||
<div className="p-num">Chapter {idx + 1}</div>
|
||||
<div className="p-num">{eyebrow}</div>
|
||||
<div className="p-title">{chapter.title || "Untitled"}</div>
|
||||
</div>
|
||||
<div className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 } from "../model/book";
|
||||
import { type Book, bodyNumber, chapterKind } from "../model/book";
|
||||
import { saveBook } from "../library";
|
||||
import { isDesktop } from "../ipc";
|
||||
import { exportEpub, exportPdf } from "../export/exporters";
|
||||
@@ -80,13 +80,16 @@ export function EditorView() {
|
||||
const coverActive = activeChapterId === COVER_ID;
|
||||
const idx = book.chapters.findIndex((c) => c.id === activeChapterId);
|
||||
const chapter = book.chapters[idx] ?? book.chapters[0];
|
||||
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
|
||||
const kind = chapter ? chapterKind(chapter) : "body";
|
||||
const eyebrow =
|
||||
kind === "body" ? `Chapter ${bodyNumber(book.chapters, realIdx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="titlebar" data-tauri-drag-region>
|
||||
<button className="doc-title" onClick={() => setSettingsOpen(true)} title="Book setup">
|
||||
{book.metadata.title || "Untitled"}
|
||||
{book.metadata.author ? ` — ${book.metadata.author}` : ""}
|
||||
{dirty && <span className="dirty-dot" />}
|
||||
</button>
|
||||
<div className="actions">
|
||||
@@ -129,11 +132,11 @@ export function EditorView() {
|
||||
<>
|
||||
<article className="sheet">
|
||||
<header className="chapter-opener">
|
||||
<div className="chapter-num">Chapter {idx + 1}</div>
|
||||
<div className="chapter-num">{eyebrow}</div>
|
||||
<input
|
||||
className="chapter-title-input"
|
||||
value={chapter.title}
|
||||
placeholder="Chapter title"
|
||||
placeholder={kind === "body" ? "Chapter title" : "Page title"}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setChapterTitle(chapter.id, e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useEffect, useState, type MouseEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type BookSummary, deleteBook, exampleBook, listBooks, loadBook, newBook, saveBook } from "../library";
|
||||
import type { Book } from "../model/book";
|
||||
import { importEpub } from "../import/epub";
|
||||
import { isDesktop } from "../ipc";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { Icon } from "./Icon";
|
||||
import { RowMenu } from "./RowMenu";
|
||||
|
||||
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
const [books, setBooks] = useState<BookSummary[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
|
||||
|
||||
const refresh = () =>
|
||||
listBooks()
|
||||
@@ -32,9 +35,10 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
onOpen(copy);
|
||||
};
|
||||
|
||||
const removeBook = async (e: MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
await deleteBook(id);
|
||||
const removeBook = async () => {
|
||||
if (!pendingDelete) return;
|
||||
await deleteBook(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
refresh();
|
||||
};
|
||||
|
||||
@@ -78,9 +82,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
<div key={b.id} className="card card-book" onClick={() => loadBook(b.id).then(onOpen)}>
|
||||
<span className="card-title">{b.title || "Untitled"}</span>
|
||||
{b.author && <span className="card-author">{b.author}</span>}
|
||||
<button className="card-delete" title="Delete book" onClick={(e) => removeBook(e, b.id)}>
|
||||
<Icon d="M6 6l12 12M18 6L6 18" size={13} />
|
||||
</button>
|
||||
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -89,6 +91,19 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
{pendingDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete book"
|
||||
message={
|
||||
<>
|
||||
Delete <strong>{pendingDelete.title || "Untitled"}</strong>? This permanently removes the book and all its
|
||||
chapters.
|
||||
</>
|
||||
}
|
||||
onConfirm={removeBook}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface RowMenuProps {
|
||||
label: string;
|
||||
onDelete: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RowMenu({ label, onDelete, className = "" }: RowMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [coords, setCoords] = useState({ top: 0, right: 0 });
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!open && btnRef.current) {
|
||||
const r = btnRef.current.getBoundingClientRect();
|
||||
setCoords({ top: r.bottom + 4, right: window.innerWidth - r.right });
|
||||
}
|
||||
setOpen((v) => !v);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (btnRef.current?.contains(e.target as Node) || popRef.current?.contains(e.target as Node)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const close = () => setOpen(false);
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("scroll", close, true);
|
||||
window.addEventListener("resize", close);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("scroll", close, true);
|
||||
window.removeEventListener("resize", close);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const remove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
onDelete();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
className={`row-menu-btn ${className}`}
|
||||
data-open={open}
|
||||
title={label}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon d="M12 5h.01M12 12h.01M12 19h.01" />
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }}>
|
||||
<button className="row-menu-item danger" onClick={remove}>
|
||||
<Icon d="M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6" size={14} />
|
||||
Delete
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+126
-41
@@ -1,32 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { COVER_ID, useBook } from "../store/useBook";
|
||||
import { type Chapter, type ChapterKind, bodyNumber, chapterKind } from "../model/book";
|
||||
import { AddPageMenu } from "./AddPageMenu";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { Icon } from "./Icon";
|
||||
import { RowMenu } from "./RowMenu";
|
||||
|
||||
interface Row {
|
||||
chapter: Chapter;
|
||||
index: number;
|
||||
num: number | null;
|
||||
}
|
||||
|
||||
function formatEdited(ts: number, now: number): string {
|
||||
const diff = Math.max(0, now - ts);
|
||||
const min = 60_000;
|
||||
const hour = 60 * min;
|
||||
const day = 24 * hour;
|
||||
if (diff < min) return "just now";
|
||||
if (diff < hour) {
|
||||
const m = Math.floor(diff / min);
|
||||
return `${m} minute${m === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
if (diff < day) {
|
||||
const h = Math.floor(diff / hour);
|
||||
return `${h} hour${h === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
if (diff < 2 * day) return "yesterday";
|
||||
const d = Math.floor(diff / day);
|
||||
if (d < 7) return `${d} days ago`;
|
||||
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const chapters = useBook((s) => s.book?.chapters ?? []);
|
||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||
const setActiveChapter = useBook((s) => s.setActiveChapter);
|
||||
const addChapter = useBook((s) => s.addChapter);
|
||||
const addPage = useBook((s) => s.addPage);
|
||||
const deleteChapter = useBook((s) => s.deleteChapter);
|
||||
const moveChapter = useBook((s) => s.moveChapter);
|
||||
const closeBook = useBook((s) => s.closeBook);
|
||||
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [drag, setDrag] = useState<{ kind: ChapterKind; index: number } | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
const onDragOver = (e: React.DragEvent, i: number) => {
|
||||
if (dragIndex === null) return;
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), 60_000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const rows: Row[] = chapters.map((chapter, index) => ({ chapter, index, num: bodyNumber(chapters, index) }));
|
||||
const groups: { kind: ChapterKind; label: string; rows: Row[] }[] = [
|
||||
{ kind: "front", label: "Front matter", rows: rows.filter((r) => chapterKind(r.chapter) === "front") },
|
||||
{ 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 ? i + 1 : i);
|
||||
setDropIndex(after ? index + 1 : index);
|
||||
};
|
||||
|
||||
const onDrop = () => {
|
||||
if (dragIndex !== null && dropIndex !== null) {
|
||||
const to = dragIndex < dropIndex ? dropIndex - 1 : dropIndex;
|
||||
moveChapter(dragIndex, to);
|
||||
if (drag && dropIndex !== null) {
|
||||
const to = drag.index < dropIndex ? dropIndex - 1 : dropIndex;
|
||||
moveChapter(drag.index, to);
|
||||
}
|
||||
setDragIndex(null);
|
||||
setDrag(null);
|
||||
setDropIndex(null);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setDrag(null);
|
||||
setDropIndex(null);
|
||||
};
|
||||
|
||||
@@ -44,38 +95,72 @@ export function Sidebar() {
|
||||
<Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} />
|
||||
<span className="title">Cover</span>
|
||||
</button>
|
||||
<div className="nav-label">Chapters</div>
|
||||
<ul className="chapters">
|
||||
{chapters.map((chapter, i) => (
|
||||
<li
|
||||
key={chapter.id}
|
||||
className="chapter"
|
||||
data-active={chapter.id === activeChapterId}
|
||||
data-dragging={dragIndex === i}
|
||||
data-drop-before={dropIndex === i}
|
||||
data-drop-after={i === chapters.length - 1 && dropIndex === chapters.length}
|
||||
draggable
|
||||
onClick={() => setActiveChapter(chapter.id)}
|
||||
onDragStart={() => setDragIndex(i)}
|
||||
onDragOver={(e) => onDragOver(e, i)}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={() => {
|
||||
setDragIndex(null);
|
||||
setDropIndex(null);
|
||||
}}
|
||||
>
|
||||
<span className="grip" title="Drag to reorder">
|
||||
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} />
|
||||
</span>
|
||||
<span className="num">{i + 1}</span>
|
||||
<span className="title">{chapter.title || "Untitled"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className="add-chapter" onClick={addChapter}>
|
||||
<Icon d="M12 5v14M5 12h14" />
|
||||
New chapter
|
||||
</button>
|
||||
<AddPageMenu onAdd={addPage} />
|
||||
|
||||
<div className="nav-scroll">
|
||||
{groups.map((group) =>
|
||||
group.kind === "body" || group.rows.length ? (
|
||||
<div key={group.kind} className="nav-section">
|
||||
<div className="nav-label">{group.label}</div>
|
||||
<ul className="chapters">
|
||||
{group.rows.map((row, i) => (
|
||||
<li
|
||||
key={row.chapter.id}
|
||||
className="chapter"
|
||||
data-active={row.chapter.id === activeChapterId}
|
||||
data-dragging={drag?.index === row.index}
|
||||
data-drop-before={drag?.kind === group.kind && dropIndex === row.index}
|
||||
data-drop-after={drag?.kind === group.kind && i === group.rows.length - 1 && dropIndex === row.index + 1}
|
||||
draggable
|
||||
onClick={() => setActiveChapter(row.chapter.id)}
|
||||
onDragStart={() => setDrag({ kind: group.kind, index: row.index })}
|
||||
onDragOver={(e) => onDragOver(e, group.kind, row.index)}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={reset}
|
||||
>
|
||||
<span className="grip" title="Drag to reorder">
|
||||
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} />
|
||||
</span>
|
||||
<span className="num">{row.num ?? ""}</span>
|
||||
<span className="text">
|
||||
<span className="title">{row.chapter.title || "Untitled"}</span>
|
||||
{row.chapter.id === activeChapterId && (
|
||||
<span className="meta">Edited {formatEdited(row.chapter.updatedAt, now)}</span>
|
||||
)}
|
||||
</span>
|
||||
<RowMenu
|
||||
label="Page options"
|
||||
onDelete={() => setPendingDelete({ id: row.chapter.id, title: row.chapter.title || "Untitled" })}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{group.kind === "body" && (
|
||||
<button className="add-chapter" onClick={addChapter}>
|
||||
<Icon d="M12 5v14M5 12h14" />
|
||||
New chapter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete"
|
||||
message={
|
||||
<>
|
||||
Delete <strong>{pendingDelete.title}</strong>? This can't be undone.
|
||||
</>
|
||||
}
|
||||
onConfirm={() => {
|
||||
deleteChapter(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
}}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
+11
-8
@@ -1,6 +1,6 @@
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { type Book, TRIM_DIMS } from "../model/book";
|
||||
import { type Book, TRIM_DIMS, bodyNumber, chapterKind } from "../model/book";
|
||||
|
||||
export interface EpubFile {
|
||||
path: string;
|
||||
@@ -277,10 +277,10 @@ function contentOpf(
|
||||
|
||||
function navXhtml(book: Book): string {
|
||||
const items = book.chapters
|
||||
.map(
|
||||
(chapter, i) =>
|
||||
` <li><a href="chapter-${i + 1}.xhtml">${esc(chapter.title || `Chapter ${i + 1}`)}</a></li>`,
|
||||
)
|
||||
.map((chapter, i) => {
|
||||
const fallback = chapterKind(chapter) === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : "Untitled";
|
||||
return ` <li><a href="chapter-${i + 1}.xhtml">${esc(chapter.title || fallback)}</a></li>`;
|
||||
})
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
|
||||
@@ -301,16 +301,19 @@ ${items}
|
||||
|
||||
function chapterXhtml(book: Book, index: number, paths: Map<string, string>): string {
|
||||
const chapter = book.chapters[index];
|
||||
const title = chapter.title || `Chapter ${index + 1}`;
|
||||
const kind = chapterKind(chapter);
|
||||
const num = bodyNumber(book.chapters, index);
|
||||
const title = chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled");
|
||||
const eyebrow = kind === "body" ? `<p class="eyebrow">Chapter ${num}</p>` : "";
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
|
||||
<head>
|
||||
<title>${esc(title)}</title>
|
||||
<link rel="stylesheet" type="text/css" href="style.css"/>
|
||||
</head>
|
||||
<body epub:type="chapter">
|
||||
<body epub:type="${kind === "body" ? "chapter" : kind === "front" ? "frontmatter" : "backmatter"}">
|
||||
<header class="chapter-opener">
|
||||
<p class="eyebrow">Chapter ${index + 1}</p>
|
||||
${eyebrow}
|
||||
<h1>${esc(title)}</h1>
|
||||
</header>
|
||||
${chapterBody(chapter.content, paths)}
|
||||
|
||||
+18
-3
@@ -1,5 +1,5 @@
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import type { Book, TrimSize } from "../model/book";
|
||||
import { type Book, type TrimSize, bodyNumber, chapterKind } from "../model/book";
|
||||
import type { ImageInput } from "../ipc";
|
||||
|
||||
const TRIM: Record<TrimSize, { w: string; h: string }> = {
|
||||
@@ -136,6 +136,15 @@ function preamble(book: Book): string {
|
||||
#heading(level: 1, numbering: none, outlined: true)[#title]
|
||||
]
|
||||
v(1.5em)
|
||||
}
|
||||
|
||||
#let pageopener(title) = {
|
||||
pagebreak(weak: true)
|
||||
v(2.1in)
|
||||
align(center)[
|
||||
#heading(level: 1, numbering: none, outlined: true)[#title]
|
||||
]
|
||||
v(1.5em)
|
||||
}`;
|
||||
}
|
||||
|
||||
@@ -216,7 +225,10 @@ export function bookToTypst(book: Book, paths: Map<string, string> = new Map(),
|
||||
|
||||
const body = book.chapters
|
||||
.map((chapter, i) => {
|
||||
const opener = `#chapteropener(${str(String(i + 1))}, [${esc(chapter.title || "Untitled")}])`;
|
||||
const opener =
|
||||
chapterKind(chapter) === "body"
|
||||
? `#chapteropener(${str(String(bodyNumber(book.chapters, i)))}, [${esc(chapter.title || "Untitled")}])`
|
||||
: `#pageopener([${esc(chapter.title || "Untitled")}])`;
|
||||
return `${opener}\n\n${chapterBody(chapter.content, paths)}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
@@ -238,7 +250,10 @@ export function coverToPdfInputs(book: Book): { source: string; images: ImageInp
|
||||
|
||||
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
|
||||
const chapter = book.chapters[index];
|
||||
const opener = `#chapteropener(${str(String(index + 1))}, [${esc(chapter.title || "Untitled")}])`;
|
||||
const opener =
|
||||
chapterKind(chapter) === "body"
|
||||
? `#chapteropener(${str(String(bodyNumber(book.chapters, index)))}, [${esc(chapter.title || "Untitled")}])`
|
||||
: `#pageopener([${esc(chapter.title || "Untitled")}])`;
|
||||
return `${preamble(book)}
|
||||
|
||||
#set page(numbering: "1")
|
||||
|
||||
@@ -354,6 +354,7 @@ function buildChapter(
|
||||
id: crypto.randomUUID(),
|
||||
title: title || `Chapter ${index + 1}`,
|
||||
content: { type: "doc", content: blocks.length ? blocks : [{ type: "paragraph" }] },
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+47
-2
@@ -52,10 +52,46 @@ export function trimRatio(trim: TrimSize): number {
|
||||
return w / h;
|
||||
}
|
||||
|
||||
export type ChapterKind = "front" | "body" | "back";
|
||||
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
title: string;
|
||||
content: JSONContent;
|
||||
updatedAt: number;
|
||||
kind?: ChapterKind;
|
||||
}
|
||||
|
||||
export interface PageType {
|
||||
id: string;
|
||||
label: string;
|
||||
group: "front" | "back";
|
||||
}
|
||||
|
||||
export const PAGE_TYPES: PageType[] = [
|
||||
{ id: "dedication", label: "Dedication", group: "front" },
|
||||
{ id: "epigraph", label: "Epigraph", group: "front" },
|
||||
{ id: "foreword", label: "Foreword", group: "front" },
|
||||
{ id: "preface", label: "Preface", group: "front" },
|
||||
{ id: "introduction", label: "Introduction", group: "front" },
|
||||
{ id: "prologue", label: "Prologue", group: "front" },
|
||||
{ id: "acknowledgments", label: "Acknowledgments", group: "front" },
|
||||
{ id: "epilogue", label: "Epilogue", group: "back" },
|
||||
{ id: "afterword", label: "Afterword", group: "back" },
|
||||
{ id: "appendix", label: "Appendix", group: "back" },
|
||||
{ id: "about-author", label: "About the Author", group: "back" },
|
||||
{ id: "other", label: "New page", group: "front" },
|
||||
];
|
||||
|
||||
export function chapterKind(chapter: Chapter): ChapterKind {
|
||||
return chapter.kind ?? "body";
|
||||
}
|
||||
|
||||
export function bodyNumber(chapters: Chapter[], index: number): number | null {
|
||||
if (index < 0 || index >= chapters.length || chapterKind(chapters[index]) !== "body") return null;
|
||||
let n = 0;
|
||||
for (let i = 0; i <= index; i++) if (chapterKind(chapters[i]) === "body") n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
export interface Book {
|
||||
@@ -77,7 +113,11 @@ function emptyDoc(): JSONContent {
|
||||
}
|
||||
|
||||
export function createChapter(title = "Untitled chapter"): Chapter {
|
||||
return { id: crypto.randomUUID(), title, content: emptyDoc() };
|
||||
return { id: crypto.randomUUID(), title, content: emptyDoc(), updatedAt: Date.now() };
|
||||
}
|
||||
|
||||
export function createPage(group: "front" | "back", title: string): Chapter {
|
||||
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: group, updatedAt: Date.now() };
|
||||
}
|
||||
|
||||
export function createCover(): Cover {
|
||||
@@ -85,7 +125,11 @@ export function createCover(): Cover {
|
||||
}
|
||||
|
||||
export function normalizeBook(book: Book): Book {
|
||||
return { ...book, cover: book.cover ? { ...createCover(), ...book.cover } : createCover() };
|
||||
return {
|
||||
...book,
|
||||
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
|
||||
chapters: book.chapters.map((c) => (c.updatedAt ? c : { ...c, updatedAt: Date.now() })),
|
||||
};
|
||||
}
|
||||
|
||||
export function createBook(): Book {
|
||||
@@ -107,6 +151,7 @@ export function starterBook(): Book {
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
title: "The Lighthouse",
|
||||
updatedAt: Date.now(),
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
|
||||
+30
-3
@@ -5,7 +5,9 @@ import {
|
||||
type BookMetadata,
|
||||
type BookSettings,
|
||||
type Cover,
|
||||
chapterKind,
|
||||
createChapter,
|
||||
createPage,
|
||||
normalizeBook,
|
||||
} from "../model/book";
|
||||
|
||||
@@ -21,6 +23,8 @@ interface BookState {
|
||||
setChapterContent: (id: string, content: JSONContent) => void;
|
||||
setChapterTitle: (id: string, title: string) => void;
|
||||
addChapter: () => void;
|
||||
addPage: (group: "front" | "back", title: string) => void;
|
||||
deleteChapter: (id: string) => void;
|
||||
moveChapter: (from: number, to: number) => void;
|
||||
setMetadata: (patch: Partial<BookMetadata>) => void;
|
||||
setSettings: (patch: Partial<BookSettings>) => void;
|
||||
@@ -41,20 +45,43 @@ export const useBook = create<BookState>((set) => ({
|
||||
setChapterContent: (id, content) =>
|
||||
set((s) =>
|
||||
s.book
|
||||
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, content } : c)) } }
|
||||
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, content, updatedAt: Date.now() } : c)) } }
|
||||
: {}
|
||||
),
|
||||
setChapterTitle: (id, title) =>
|
||||
set((s) =>
|
||||
s.book
|
||||
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, title } : c)) } }
|
||||
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, title, updatedAt: Date.now() } : c)) } }
|
||||
: {}
|
||||
),
|
||||
addChapter: () =>
|
||||
set((s) => {
|
||||
if (!s.book) return {};
|
||||
const chapter = createChapter();
|
||||
return { activeChapterId: chapter.id, dirty: true, book: { ...s.book, chapters: [...s.book.chapters, chapter] } };
|
||||
const chapters = [...s.book.chapters];
|
||||
const backCount = chapters.filter((c) => chapterKind(c) === "back").length;
|
||||
chapters.splice(chapters.length - backCount, 0, chapter);
|
||||
return { activeChapterId: chapter.id, dirty: true, book: { ...s.book, chapters } };
|
||||
}),
|
||||
addPage: (group, title) =>
|
||||
set((s) => {
|
||||
if (!s.book) return {};
|
||||
const page = createPage(group, title);
|
||||
const chapters = [...s.book.chapters];
|
||||
const insertAt =
|
||||
group === "front" ? chapters.filter((c) => chapterKind(c) === "front").length : chapters.length;
|
||||
chapters.splice(insertAt, 0, page);
|
||||
return { activeChapterId: page.id, dirty: true, book: { ...s.book, chapters } };
|
||||
}),
|
||||
deleteChapter: (id) =>
|
||||
set((s) => {
|
||||
if (!s.book) return {};
|
||||
const index = s.book.chapters.findIndex((c) => c.id === id);
|
||||
if (index === -1) return {};
|
||||
const chapters = s.book.chapters.filter((c) => c.id !== id);
|
||||
const activeChapterId =
|
||||
s.activeChapterId === id ? (chapters[index] ?? chapters[index - 1])?.id ?? COVER_ID : s.activeChapterId;
|
||||
return { dirty: true, activeChapterId, book: { ...s.book, chapters } };
|
||||
}),
|
||||
moveChapter: (from, to) =>
|
||||
set((s) => {
|
||||
|
||||
+213
-16
@@ -193,6 +193,28 @@ button {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.chapter .text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chapter .title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chapter .meta {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.chapter .grip {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -236,6 +258,69 @@ button {
|
||||
bottom: -1px;
|
||||
}
|
||||
|
||||
.row-menu-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-faint);
|
||||
transition: opacity 120ms var(--ease), background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.row-menu-btn:hover {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.chapter .row-menu-btn {
|
||||
align-self: center;
|
||||
margin-right: -4px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chapter:hover .row-menu-btn,
|
||||
.chapter .row-menu-btn[data-open="true"] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chapter:hover .row-menu-btn:hover,
|
||||
.chapter .row-menu-btn[data-open="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row-menu-pop {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
min-width: 148px;
|
||||
padding: 5px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
.row-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink-soft);
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.row-menu-item.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.row-menu-item.danger:hover {
|
||||
background: var(--danger-wash);
|
||||
}
|
||||
|
||||
.add-chapter {
|
||||
margin: 8px 18px 4px;
|
||||
padding: 9px 12px;
|
||||
@@ -1021,51 +1106,80 @@ button {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card-delete {
|
||||
.card-menu {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: var(--ink-faint);
|
||||
opacity: 0;
|
||||
transition: opacity 120ms var(--ease), background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.card-book:hover .card-delete {
|
||||
.card-book:hover .card-menu,
|
||||
.card-menu[data-open="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-delete:hover {
|
||||
background: var(--accent-wash);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 14px 22px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
.panel-confirm {
|
||||
width: 420px;
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
margin: 0;
|
||||
font-size: var(--t-4);
|
||||
line-height: 1.5;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.confirm-text strong {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-ghost,
|
||||
.btn-danger {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
font-weight: 500;
|
||||
padding: 9px 20px;
|
||||
border-radius: var(--r-sm);
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
transition: background 120ms var(--ease);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-ink);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: var(--danger-contrast);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--danger-ink);
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.cover-item {
|
||||
position: relative;
|
||||
@@ -1300,3 +1414,86 @@ button {
|
||||
color: var(--ink-faint);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Add-page menu + front/back matter sections */
|
||||
.add-page {
|
||||
margin: 2px 12px 2px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: var(--r-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink-faint);
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.add-page:hover,
|
||||
.add-page[data-open="true"] {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.add-page-pop {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
min-width: 178px;
|
||||
max-height: 72vh;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
.add-page-group + .add-page-group {
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.add-page-label {
|
||||
padding: 6px 10px 3px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.add-page-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink-soft);
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.add-page-item:hover {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.nav-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-scroll .chapters {
|
||||
flex: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.nav-section + .nav-section {
|
||||
margin-top: 6px;
|
||||
}
|
||||
@@ -39,6 +39,11 @@
|
||||
--selection: rgba(35, 32, 27, 0.14);
|
||||
--glass: rgba(252, 251, 247, 0.86);
|
||||
|
||||
--danger: #b4453a;
|
||||
--danger-ink: #963327;
|
||||
--danger-wash: rgba(180, 69, 58, 0.1);
|
||||
--danger-contrast: #faf7f0;
|
||||
|
||||
--shadow-page: 0 1px 2px rgba(35, 32, 27, 0.06), 0 14px 30px rgba(35, 32, 27, 0.09);
|
||||
--shadow-pop: 0 8px 24px rgba(35, 32, 27, 0.14);
|
||||
}
|
||||
@@ -63,6 +68,11 @@
|
||||
--selection: rgba(239, 233, 220, 0.18);
|
||||
--glass: rgba(33, 30, 25, 0.88);
|
||||
|
||||
--danger: #d97a6f;
|
||||
--danger-ink: #e58e84;
|
||||
--danger-wash: rgba(217, 122, 111, 0.16);
|
||||
--danger-contrast: #1a1714;
|
||||
|
||||
--shadow-page: 0 1px 2px rgba(0, 0, 0, 0.4), 0 14px 34px rgba(0, 0, 0, 0.5);
|
||||
--shadow-pop: 0 8px 24px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
Reference in new issue
Block a user