This commit is contained in:
pj committed 2026-06-20 23:46:10 -04:00
1 parent c86d5a7d5c
commit 0e0d334f6a
33 files changed
+401 -101

No files matched your search

+3 -3
View File
@@ -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<string>("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());
+15 -20
View File
@@ -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<string | null>(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<void>) => {
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() {
<>
<div className="menu-backdrop" onClick={() => setExportOpen(false)} />
<div className="menu">
<button onClick={() => runExport("PDF", exportPdf)}>Export PDF…</button>
<button onClick={() => runExport("EPUB", exportEpub)}>Export EPUB…</button>
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
</div>
</>
)}
@@ -156,6 +145,12 @@ export function EditorView() {
</div>
{settingsOpen && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />}
{exporting && (
<div className="export-overlay">
<div className="spinner" />
<p>Exporting {exporting}…</p>
</div>
)}
{notice && (
<div className="toast" onClick={() => setNotice(null)}>
{notice}
+91 -30
View File
@@ -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<number | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
const [now, setNow] = useState(() => Date.now());
const dropRef = useRef<DropTarget | null>(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<ChapterKind, number> = {
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<HTMLElement>(".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<HTMLElement>(".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() {
<div className="nav-scroll">
{groups.map((group) =>
group.kind === "body" || group.rows.length ? (
<div key={group.kind} className="nav-section">
group.kind === "body" || group.rows.length || dragging ? (
<div key={group.kind} className="nav-section" data-kind={group.kind}>
<div className="nav-label">{group.label}</div>
<ul className="chapters">
{group.rows.map((row, i) => (
<li
key={row.chapter.id}
className="chapter"
data-idx={row.index}
data-kind={group.kind}
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}
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)}
>
<span className="grip" title="Drag to reorder">
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} />
@@ -134,6 +190,11 @@ export function Sidebar() {
/>
</li>
))}
{!group.rows.length && (
<li className="chapter-drop" data-on={dropTarget?.kind === group.kind}>
Drop here
</li>
)}
</ul>
{group.kind === "body" && (
<button className="add-chapter" onClick={addChapter}>
+43 -2
View File
@@ -1,4 +1,6 @@
import { useRef, useState } from "react";
import { NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
import { FIGURE_WIDTH, isResizablePlacement, type FigurePlacement } from "../model/book";
const PLACEMENTS = [
{ key: "inline", label: "Inline" },
@@ -7,13 +9,51 @@ const PLACEMENTS = [
{ key: "float-top", label: "Float" },
];
const clampWidth = (value: number) => Math.min(100, Math.max(20, Math.round(value)));
export function FigureView({ node, updateAttributes, selected }: NodeViewProps) {
const { src, alt, caption, placement } = node.attrs;
const frameRef = useRef<HTMLDivElement>(null);
const [dragWidth, setDragWidth] = useState<number | null>(null);
const resizable = isResizablePlacement(placement as FigurePlacement);
const baseWidth = node.attrs.width ?? FIGURE_WIDTH[placement as FigurePlacement] ?? 100;
const width = resizable ? (dragWidth ?? baseWidth) : null;
const startResize = (e: React.PointerEvent) => {
e.preventDefault();
const wrapper = frameRef.current?.closest(".figure") as HTMLElement | null;
const container = wrapper?.parentElement;
if (!wrapper || !container) return;
const startX = e.clientX;
const startPx = wrapper.getBoundingClientRect().width;
const containerPx = container.clientWidth || 1;
const percentAt = (clientX: number) => clampWidth(((startPx + clientX - startX) / containerPx) * 100);
const onMove = (ev: PointerEvent) => setDragWidth(percentAt(ev.clientX));
const onUp = (ev: PointerEvent) => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
updateAttributes({ width: percentAt(ev.clientX) });
setDragWidth(null);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
return (
<NodeViewWrapper className="figure" data-placement={placement} data-selected={selected} contentEditable={false}>
<div className="figure-frame">
<NodeViewWrapper
className="figure"
data-placement={placement}
data-selected={selected}
style={width != null ? { width: `${width}%` } : undefined}
contentEditable={false}
>
<div className="figure-frame" ref={frameRef}>
{src ? <img src={src} alt={alt} /> : <div className="figure-empty">No image</div>}
{selected && src && resizable && (
<div className="figure-handle" onPointerDown={startResize} title="Drag to resize" />
)}
</div>
<input
className="figure-caption"
@@ -28,6 +68,7 @@ export function FigureView({ node, updateAttributes, selected }: NodeViewProps)
{p.label}
</button>
))}
{resizable && src && <span className="figure-width">{Math.round(width ?? baseWidth)}%</span>}
</div>
)}
</NodeViewWrapper>
+5 -2
View File
@@ -1,5 +1,6 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { isResizablePlacement } from "../model/book";
import { FigureView } from "./FigureView";
export const Figure = Node.create({
@@ -15,6 +16,7 @@ export const Figure = Node.create({
alt: { default: "" },
caption: { default: "" },
placement: { default: "full-width" },
width: { default: null },
};
},
@@ -23,10 +25,11 @@ export const Figure = Node.create({
},
renderHTML({ HTMLAttributes }) {
const { src, alt, caption, placement } = HTMLAttributes;
const { src, alt, caption, placement, width } = HTMLAttributes;
const style = width != null && isResizablePlacement(placement) ? `width:${width}%` : null;
return [
"figure",
mergeAttributes({ "data-figure": "", "data-placement": placement }),
mergeAttributes({ "data-figure": "", "data-placement": placement, style }),
["img", { src, alt }],
["figcaption", {}, caption ?? ""],
];
+6 -3
View File
@@ -1,6 +1,6 @@
import type { JSONContent } from "@tiptap/core";
import { invoke } from "@tauri-apps/api/core";
import { type Book, TRIM_DIMS, bodyNumber, chapterKind } from "../model/book";
import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
export interface EpubFile {
path: string;
@@ -72,7 +72,10 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
const caption = node.attrs?.caption
? `<figcaption>${esc(node.attrs.caption)}</figcaption>`
: "";
return `<figure class="placement-${attr(placement)}"><img src="${attr(path)}" alt="${alt}"/>${caption}</figure>`;
const placed = placement as FigurePlacement;
const width = (isResizablePlacement(placed) ? node.attrs?.width : null) ?? FIGURE_WIDTH[placed] ?? 100;
const style = placement === "full-page" ? "" : ` style="width:${width}%"`;
return `<figure class="placement-${attr(placement)}"${style}><img src="${attr(path)}" alt="${alt}"/>${caption}</figure>`;
}
function block(node: JSONContent, paths: Map<string, string>): string {
@@ -439,7 +442,7 @@ hr.scene-break::before {
}
figure {
margin: 1.4em 0;
margin: 1.4em auto;
text-align: center;
}
+37
View File
@@ -0,0 +1,37 @@
import type { Book } from "../model/book";
import { exportEpub, exportPdf } from "./exporters";
import { useBook } from "../store/useBook";
import { isDesktop } from "../ipc";
const DESKTOP_ONLY =
'Export runs in the desktop app only — open the window from "pnpm tauri dev" (you are viewing the browser preview).';
const FORMATS: Record<string, { label: string; run: (book: Book) => Promise<void> }> = {
pdf: { label: "PDF", run: exportPdf },
epub: { label: "EPUB", run: exportEpub },
};
function nextPaint(): Promise<void> {
return new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
);
}
export async function runExport(format: "pdf" | "epub"): Promise<void> {
const { book, setExporting, setNotice } = useBook.getState();
const { label, run } = FORMATS[format];
if (!isDesktop) {
setNotice(DESKTOP_ONLY);
return;
}
if (!book) return;
setExporting(label);
await nextPaint();
try {
await run(book);
} catch (e) {
setNotice(`${label} export failed: ${e}`);
} finally {
setExporting(null);
}
}
+71 -27
View File
@@ -1,5 +1,5 @@
import type { JSONContent } from "@tiptap/core";
import { type Book, type TrimSize, bodyNumber, chapterKind } from "../model/book";
import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
import type { ImageInput } from "../ipc";
const TRIM: Record<TrimSize, { w: string; h: string }> = {
@@ -57,9 +57,11 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
if (placement === "full-page") {
return `#page(margin: 0pt, numbering: none)[#image(${str(path)}, width: 100%, height: 100%, fit: "cover")]`;
}
const placed = placement as FigurePlacement;
const width = (isResizablePlacement(placed) ? node.attrs?.width : null) ?? FIGURE_WIDTH[placed] ?? 100;
const caption = node.attrs?.caption ? `, caption: [${esc(node.attrs.caption)}]` : "";
const float = placement === "float-top" ? ", placement: top" : "";
return `#figure(image(${str(path)}, width: 100%)${caption}${float})`;
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
}
function block(node: JSONContent, paths: Map<string, string>): string {
@@ -127,24 +129,52 @@ function preamble(book: Book): string {
]
}
#let chapteropener(num, title) = {
#let openchapter(info) = {
pagebreak(weak: true)
[#metadata(info) <chap>]
v(2.1in)
align(center)[
#text(font: "Hanken Grotesk", size: 8.5pt, weight: "semibold", tracking: 0.28em)[#upper("Chapter " + num)]
#v(0.7em)
#heading(level: 1, numbering: none, outlined: true)[#title]
#if info.kind == "body" {
text(font: "Hanken Grotesk", size: 8.5pt, weight: "semibold", tracking: 0.28em)[#upper("Chapter " + info.num)]
v(0.7em)
}
#heading(level: 1, numbering: none, outlined: info.toc)[#info.title]
]
v(1.5em)
}
#let pageopener(title) = {
#let pageof(loc) = {
let pat = loc.page-numbering()
if pat == none { pat = "1" }
numbering(pat, ..counter(page).at(loc))
}
#let contents() = {
pagebreak(weak: true)
v(2.1in)
align(center)[
#heading(level: 1, numbering: none, outlined: true)[#title]
]
v(1.5em)
set par(justify: false, first-line-indent: 0pt)
block(below: 2em)[#text(font: "Literata", size: 24pt, weight: "medium")[Contents]]
context {
let items = query(<chap>).filter(it => it.value.toc != false)
let prev = none
for it in items {
let info = it.value
let pg = pageof(it.location())
let body = info.kind == "body"
let gap = if prev != none and prev != info.kind { 1.7em } else { 0.95em }
prev = info.kind
block(width: 100%, above: gap, below: 0.95em, {
set text(size: 11.5pt)
link(it.location())[#grid(
columns: (1.9em, 1fr, auto),
column-gutter: (0.65em, 1em),
align: (right + top, left + top, right + top),
if body { info.num } else { [] },
if body { info.title } else { emph(info.title) },
pg,
)]
})
}
}
}`;
}
@@ -213,23 +243,42 @@ export function extractImages(book: Book): { images: ImageInput[]; paths: Map<st
return collectImages(book.chapters.map((chapter) => chapter.content));
}
function cleanTitle(title: string): string {
return (title || "")
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function normTitle(title: string): string {
return cleanTitle(title).toLowerCase();
}
function openerCall(book: Book, index: number): string {
const chapter = book.chapters[index];
const kind = chapterKind(chapter);
const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
const title = cleanTitle(chapter.title) || "Untitled";
const inToc = !(kind === "front" && normTitle(title) === normTitle(book.metadata.title));
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, toc: ${inToc}))`;
}
export function bookToTypst(book: Book, paths: Map<string, string> = new Map(), coverPath?: string): string {
const meta = book.metadata;
const cover = `${coverBlock(book, coverPath)}\n#pagebreak(weak: true)`;
const front = `#set page(numbering: none)
#titlepage(${str(meta.title || "Untitled")}, ${str(meta.subtitle)}, ${str(meta.author)})
#pagebreak(weak: true)
#outline(title: [Contents], depth: 1)
#set page(numbering: "1")
#counter(page).update(1)`;
#set page(numbering: "i")
#counter(page).update(1)
#contents()`;
const firstBody = book.chapters.findIndex((c) => chapterKind(c) === "body");
const body = book.chapters
.map((chapter, i) => {
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)}`;
const reset = i === firstBody ? `#set page(numbering: "1")\n#counter(page).update(1)\n` : "";
return `${reset}${openerCall(book, i)}\n\n${chapterBody(chapter.content, paths)}`;
})
.join("\n\n");
@@ -249,19 +298,14 @@ 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 =
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")
#counter(page).update(1)
${opener}
${openerCall(book, index)}
${chapterBody(chapter.content, paths)}
${chapterBody(book.chapters[index].content, paths)}
`;
}
+15 -5
View File
@@ -266,6 +266,13 @@ function readSpine(opf: Document, manifest: Map<string, ManifestItem>): Manifest
.filter((item) => /xhtml|html/.test(item.mediaType) || /\.x?html?$/.test(item.href));
}
function cleanLabel(text: string | null | undefined): string {
return (text ?? "")
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function readNavTitles(
manifest: Map<string, ManifestItem>,
byPath: Map<string, RawFile>,
@@ -284,9 +291,9 @@ function readNavTitles(
const doc = parseXml(file.data, "text/html");
const baseDir = dirOf(nav.href);
doc.querySelectorAll("a[href]").forEach((a) => {
const href = a.getAttribute("href")!;
const label = a.textContent?.trim();
if (label) titles.set(resolvePath(baseDir, decodeHref(href)), label);
const key = resolvePath(baseDir, decodeHref(a.getAttribute("href")!));
const label = cleanLabel(a.textContent);
if (label && !titles.has(key)) titles.set(key, label);
});
}
}
@@ -298,9 +305,12 @@ function readNavTitles(
Array.from(doc.getElementsByTagName("*"))
.filter((el) => el.localName === "navPoint")
.forEach((point) => {
const label = firstByLocal(point, "text")?.textContent?.trim();
const label = cleanLabel(firstByLocal(point, "text")?.textContent);
const src = firstByLocal(point, "content")?.getAttribute("src");
if (label && src) titles.set(resolvePath(baseDir, decodeHref(src)), label);
if (label && src) {
const key = resolvePath(baseDir, decodeHref(src));
if (!titles.has(key)) titles.set(key, label);
}
});
}
}
+10
View File
@@ -3,6 +3,16 @@ import type { JSONContent } from "@tiptap/core";
export type TrimSize = "6x9" | "5.5x8.5" | "5x8" | "a5";
export type FigurePlacement = "inline" | "full-width" | "full-page" | "float-top";
export const FIGURE_WIDTH: Record<FigurePlacement, number> = {
inline: 62,
"full-width": 100,
"full-page": 100,
"float-top": 46,
};
export const isResizablePlacement = (placement: FigurePlacement): boolean =>
placement === "inline" || placement === "float-top";
export interface BookMetadata {
title: string;
subtitle: string;
+18 -5
View File
@@ -4,6 +4,7 @@ import {
type Book,
type BookMetadata,
type BookSettings,
type ChapterKind,
type Cover,
chapterKind,
createChapter,
@@ -17,6 +18,10 @@ interface BookState {
book: Book | null;
activeChapterId: string;
dirty: boolean;
exporting: string | null;
notice: string | null;
setExporting: (label: string | null) => void;
setNotice: (message: string | null) => void;
openBook: (book: Book) => void;
closeBook: () => void;
setActiveChapter: (id: string) => void;
@@ -25,7 +30,7 @@ interface BookState {
addChapter: () => void;
addPage: (group: "front" | "back", title: string) => void;
deleteChapter: (id: string) => void;
moveChapter: (from: number, to: number) => void;
moveChapter: (from: number, to: number, toKind?: ChapterKind) => void;
setMetadata: (patch: Partial<BookMetadata>) => void;
setSettings: (patch: Partial<BookSettings>) => void;
setCover: (patch: Partial<Cover>) => void;
@@ -36,6 +41,10 @@ export const useBook = create<BookState>((set) => ({
book: null,
activeChapterId: "",
dirty: false,
exporting: null,
notice: null,
setExporting: (label) => set({ exporting: label }),
setNotice: (message) => set({ notice: message }),
openBook: (book) => {
const normalized = normalizeBook(book);
set({ book: normalized, activeChapterId: normalized.chapters[0]?.id ?? "", dirty: false });
@@ -83,13 +92,17 @@ export const useBook = create<BookState>((set) => ({
s.activeChapterId === id ? (chapters[index] ?? chapters[index - 1])?.id ?? COVER_ID : s.activeChapterId;
return { dirty: true, activeChapterId, book: { ...s.book, chapters } };
}),
moveChapter: (from, to) =>
moveChapter: (from, to, toKind) =>
set((s) => {
if (!s.book || from === to) return {};
if (!s.book) return {};
const chapters = [...s.book.chapters];
if (from < 0 || from >= chapters.length || to < 0 || to >= chapters.length) return {};
if (from < 0 || from >= chapters.length) return {};
const target = chapters[from];
const nextKind = toKind ?? chapterKind(target);
const dest = Math.max(0, Math.min(from < to ? to - 1 : to, chapters.length - 1));
if (from === dest && chapterKind(target) === nextKind) return {};
const [moved] = chapters.splice(from, 1);
chapters.splice(to, 0, moved);
chapters.splice(dest, 0, nextKind === "body" ? { ...moved, kind: undefined } : { ...moved, kind: nextKind });
return { dirty: true, book: { ...s.book, chapters } };
}),
setMetadata: (patch) =>
+85 -2
View File
@@ -106,6 +106,8 @@ button {
display: flex;
flex-direction: column;
padding: 6px 0 10px;
user-select: none;
-webkit-user-select: none;
}
.brand {
@@ -322,10 +324,12 @@ button {
}
.add-chapter {
margin: 8px 18px 4px;
margin: 8px 10px 4px;
width: calc(100% - 20px);
padding: 9px 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: var(--r-sm);
color: var(--ink-faint);
@@ -739,7 +743,7 @@ button {
}
.figure {
margin: 1.8rem 0;
margin: 1.8rem auto;
}
.figure img {
@@ -760,11 +764,25 @@ button {
}
.figure-frame {
position: relative;
background: var(--sidebar);
border-radius: 3px;
overflow: hidden;
}
.figure-handle {
position: absolute;
right: 6px;
bottom: 6px;
width: 14px;
height: 14px;
border-radius: 3px;
background: var(--accent);
border: 2px solid var(--accent-contrast);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
cursor: nwse-resize;
}
.figure-empty {
aspect-ratio: 3 / 2;
display: grid;
@@ -800,12 +818,15 @@ button {
.figure-controls {
display: flex;
flex-wrap: nowrap;
gap: 4px;
justify-content: center;
margin-top: 9px;
}
.figure-controls button {
flex-shrink: 0;
white-space: nowrap;
font-family: var(--font-ui);
font-size: var(--t-1);
padding: 5px 10px;
@@ -820,6 +841,15 @@ button {
color: var(--accent-contrast);
}
.figure-width {
align-self: center;
margin-left: 2px;
font-family: var(--font-ui);
font-size: var(--t-1);
color: var(--ink-faint);
font-variant-numeric: tabular-nums;
}
.pdf-pages {
display: flex;
flex-direction: column;
@@ -1003,6 +1033,42 @@ button {
color: var(--ink);
}
.export-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
background: rgba(35, 32, 27, 0.32);
-webkit-backdrop-filter: blur(3px);
backdrop-filter: blur(3px);
}
.export-overlay p {
margin: 0;
font-family: var(--font-ui);
font-size: var(--t-3);
color: #fcfbf7;
}
.spinner {
width: 34px;
height: 34px;
border: 3px solid rgba(255, 255, 255, 0.28);
border-top-color: #fcfbf7;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.toast {
position: fixed;
bottom: 26px;
@@ -1497,3 +1563,20 @@ button {
.nav-section + .nav-section {
margin-top: 6px;
}
.chapter-drop {
margin: 2px 0;
padding: 9px 12px;
border-radius: var(--r-sm);
border: 1px dashed var(--line-strong);
color: var(--ink-faint);
font-size: var(--t-2);
text-align: center;
transition: border-color 120ms var(--ease), color 120ms var(--ease), background 120ms var(--ease);
}
.chapter-drop[data-on="true"] {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-wash);
}