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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 B

After

Width:  |  Height:  |  Size: 1017 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+1 -1
View File
@@ -59,7 +59,7 @@ fn build(files: &[EpubFile]) -> Result<Vec<u8>, String> {
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner()) Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
} }
#[tauri::command] #[tauri::command(async)]
pub fn package_epub(files: Vec<EpubFile>) -> Result<tauri::ipc::Response, String> { pub fn package_epub(files: Vec<EpubFile>) -> Result<tauri::ipc::Response, String> {
build(&files).map(tauri::ipc::Response::new) build(&files).map(tauri::ipc::Response::new)
} }
+1 -1
View File
@@ -36,7 +36,7 @@ fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> {
typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d)) 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<ImageInput>) -> Result<tauri::ipc::Response, String> { pub fn compile_pdf(source: String, images: Vec<ImageInput>) -> Result<tauri::ipc::Response, String> {
compile(source, &images).map(tauri::ipc::Response::new) compile(source, &images).map(tauri::ipc::Response::new)
} }
+3 -3
View File
@@ -5,7 +5,7 @@ import { EditorView } from "./components/EditorView";
import { useBook } from "./store/useBook"; import { useBook } from "./store/useBook";
import { isDesktop } from "./ipc"; import { isDesktop } from "./ipc";
import { newBook } from "./library"; import { newBook } from "./library";
import { exportEpub, exportPdf } from "./export/exporters"; import { runExport } from "./export/run";
function App() { function App() {
const book = useBook((s) => s.book); const book = useBook((s) => s.book);
@@ -16,8 +16,8 @@ function App() {
const unlisten = listen<string>("menu-action", (event) => { const unlisten = listen<string>("menu-action", (event) => {
const state = useBook.getState(); const state = useBook.getState();
if (event.payload === "new-book") state.openBook(newBook()); if (event.payload === "new-book") state.openBook(newBook());
else if (event.payload === "export-pdf" && state.book) exportPdf(state.book); else if (event.payload === "export-pdf") runExport("pdf");
else if (event.payload === "export-epub" && state.book) exportEpub(state.book); else if (event.payload === "export-epub") runExport("epub");
}); });
return () => { return () => {
unlisten.then((stop) => stop()); unlisten.then((stop) => stop());
+15 -20
View File
@@ -9,18 +9,18 @@ import { Editor } from "../editor/Editor";
import { FloatingToolbar } from "../editor/FloatingToolbar"; import { FloatingToolbar } from "../editor/FloatingToolbar";
import { COVER_ID, useBook } from "../store/useBook"; import { COVER_ID, useBook } from "../store/useBook";
import { useTheme } from "../store/useTheme"; import { useTheme } from "../store/useTheme";
import { type Book, bodyNumber, chapterKind } from "../model/book"; import { bodyNumber, chapterKind } from "../model/book";
import { saveBook } from "../library"; import { saveBook } from "../library";
import { isDesktop } from "../ipc"; import { isDesktop } from "../ipc";
import { exportEpub, exportPdf } from "../export/exporters"; import { runExport } from "../export/run";
const DESKTOP_ONLY =
'Export runs in the desktop app only — open the window from "pnpm tauri dev" (you are viewing the browser preview).';
export function EditorView() { export function EditorView() {
const book = useBook((s) => s.book); const book = useBook((s) => s.book);
const activeChapterId = useBook((s) => s.activeChapterId); const activeChapterId = useBook((s) => s.activeChapterId);
const dirty = useBook((s) => s.dirty); 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 setChapterContent = useBook((s) => s.setChapterContent);
const setChapterTitle = useBook((s) => s.setChapterTitle); const setChapterTitle = useBook((s) => s.setChapterTitle);
const markSaved = useBook((s) => s.markSaved); const markSaved = useBook((s) => s.markSaved);
@@ -31,7 +31,6 @@ export function EditorView() {
const [dock, setDock] = useState(true); const [dock, setDock] = useState(true);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const saveNow = useCallback(() => { const saveNow = useCallback(() => {
const current = useBook.getState().book; const current = useBook.getState().book;
@@ -61,19 +60,9 @@ export function EditorView() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [notice]); }, [notice]);
const runExport = async (label: string, fn: (book: Book) => Promise<void>) => { const handleExport = (format: "pdf" | "epub") => {
setExportOpen(false); setExportOpen(false);
if (!isDesktop) { runExport(format);
setNotice(DESKTOP_ONLY);
return;
}
const current = useBook.getState().book;
if (!current) return;
try {
await fn(current);
} catch (e) {
setNotice(`${label} export failed: ${e}`);
}
}; };
if (!book) return null; if (!book) return null;
@@ -101,8 +90,8 @@ export function EditorView() {
<> <>
<div className="menu-backdrop" onClick={() => setExportOpen(false)} /> <div className="menu-backdrop" onClick={() => setExportOpen(false)} />
<div className="menu"> <div className="menu">
<button onClick={() => runExport("PDF", exportPdf)}>Export PDF…</button> <button onClick={() => handleExport("pdf")}>Export PDF…</button>
<button onClick={() => runExport("EPUB", exportEpub)}>Export EPUB…</button> <button onClick={() => handleExport("epub")}>Export EPUB…</button>
</div> </div>
</> </>
)} )}
@@ -156,6 +145,12 @@ export function EditorView() {
</div> </div>
{settingsOpen && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />} {settingsOpen && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />}
{exporting && (
<div className="export-overlay">
<div className="spinner" />
<p>Exporting {exporting}…</p>
</div>
)}
{notice && ( {notice && (
<div className="toast" onClick={() => setNotice(null)}> <div className="toast" onClick={() => setNotice(null)}>
{notice} {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 { COVER_ID, useBook } from "../store/useBook";
import { type Chapter, type ChapterKind, bodyNumber, chapterKind } from "../model/book"; import { type Chapter, type ChapterKind, bodyNumber, chapterKind } from "../model/book";
import { AddPageMenu } from "./AddPageMenu"; import { AddPageMenu } from "./AddPageMenu";
@@ -12,6 +12,11 @@ interface Row {
num: number | null; num: number | null;
} }
interface DropTarget {
kind: ChapterKind;
index: number;
}
function formatEdited(ts: number, now: number): string { function formatEdited(ts: number, now: number): string {
const diff = Math.max(0, now - ts); const diff = Math.max(0, now - ts);
const min = 60_000; const min = 60_000;
@@ -42,11 +47,15 @@ export function Sidebar() {
const moveChapter = useBook((s) => s.moveChapter); const moveChapter = useBook((s) => s.moveChapter);
const closeBook = useBook((s) => s.closeBook); const closeBook = useBook((s) => s.closeBook);
const [drag, setDrag] = useState<{ kind: ChapterKind; index: number } | null>(null); const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null); const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null); const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
const [now, setNow] = useState(() => Date.now()); 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(() => { useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 60_000); const timer = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(timer); return () => clearInterval(timer);
@@ -58,27 +67,76 @@ export function Sidebar() {
{ kind: "body", label: "Chapters", rows: rows.filter((r) => chapterKind(r.chapter) === "body") }, { 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") }, { kind: "back", label: "Back matter", rows: rows.filter((r) => chapterKind(r.chapter) === "back") },
]; ];
const groupEnd: Record<ChapterKind, number> = {
const onDragOver = (e: React.DragEvent, kind: ChapterKind, index: number) => { front: groups[0].rows.length,
if (!drag || drag.kind !== kind) return; body: groups[0].rows.length + groups[1].rows.length,
e.preventDefault(); back: rows.length,
const rect = e.currentTarget.getBoundingClientRect();
const after = e.clientY > rect.top + rect.height / 2;
setDropIndex(after ? index + 1 : index);
}; };
const onDrop = () => { const dragging = dragIndex !== null;
if (drag && dropIndex !== null) {
const to = drag.index < dropIndex ? dropIndex - 1 : dropIndex; const setDrop = (t: DropTarget | null) => {
moveChapter(drag.index, to); 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); const section = el?.closest<HTMLElement>(".nav-section");
setDropIndex(null); if (section?.dataset.kind) {
const kind = section.dataset.kind as ChapterKind;
return { kind, index: groupEnd[kind] };
}
return null;
}; };
const reset = () => { const onPointerMove = (e: PointerEvent) => {
setDrag(null); const g = gesture.current;
setDropIndex(null); 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 ( return (
@@ -99,24 +157,22 @@ export function Sidebar() {
<div className="nav-scroll"> <div className="nav-scroll">
{groups.map((group) => {groups.map((group) =>
group.kind === "body" || group.rows.length ? ( group.kind === "body" || group.rows.length || dragging ? (
<div key={group.kind} className="nav-section"> <div key={group.kind} className="nav-section" data-kind={group.kind}>
<div className="nav-label">{group.label}</div> <div className="nav-label">{group.label}</div>
<ul className="chapters"> <ul className="chapters">
{group.rows.map((row, i) => ( {group.rows.map((row, i) => (
<li <li
key={row.chapter.id} key={row.chapter.id}
className="chapter" className="chapter"
data-idx={row.index}
data-kind={group.kind}
data-active={row.chapter.id === activeChapterId} data-active={row.chapter.id === activeChapterId}
data-dragging={drag?.index === row.index} data-dragging={dragIndex === row.index}
data-drop-before={drag?.kind === group.kind && dropIndex === row.index} data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index}
data-drop-after={drag?.kind === group.kind && i === group.rows.length - 1 && dropIndex === row.index + 1} data-drop-after={dropTarget?.kind === group.kind && i === group.rows.length - 1 && dropTarget.index === row.index + 1}
draggable onClick={() => onRowClick(row.chapter.id)}
onClick={() => setActiveChapter(row.chapter.id)} onPointerDown={(e) => onRowPointerDown(e, row.index)}
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"> <span className="grip" title="Drag to reorder">
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} /> <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> </li>
))} ))}
{!group.rows.length && (
<li className="chapter-drop" data-on={dropTarget?.kind === group.kind}>
Drop here
</li>
)}
</ul> </ul>
{group.kind === "body" && ( {group.kind === "body" && (
<button className="add-chapter" onClick={addChapter}> <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 { NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
import { FIGURE_WIDTH, isResizablePlacement, type FigurePlacement } from "../model/book";
const PLACEMENTS = [ const PLACEMENTS = [
{ key: "inline", label: "Inline" }, { key: "inline", label: "Inline" },
@@ -7,13 +9,51 @@ const PLACEMENTS = [
{ key: "float-top", label: "Float" }, { 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) { export function FigureView({ node, updateAttributes, selected }: NodeViewProps) {
const { src, alt, caption, placement } = node.attrs; 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 ( return (
<NodeViewWrapper className="figure" data-placement={placement} data-selected={selected} contentEditable={false}> <NodeViewWrapper
<div className="figure-frame"> 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>} {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> </div>
<input <input
className="figure-caption" className="figure-caption"
@@ -28,6 +68,7 @@ export function FigureView({ node, updateAttributes, selected }: NodeViewProps)
{p.label} {p.label}
</button> </button>
))} ))}
{resizable && src && <span className="figure-width">{Math.round(width ?? baseWidth)}%</span>}
</div> </div>
)} )}
</NodeViewWrapper> </NodeViewWrapper>
+5 -2
View File
@@ -1,5 +1,6 @@
import { Node, mergeAttributes } from "@tiptap/core"; import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { isResizablePlacement } from "../model/book";
import { FigureView } from "./FigureView"; import { FigureView } from "./FigureView";
export const Figure = Node.create({ export const Figure = Node.create({
@@ -15,6 +16,7 @@ export const Figure = Node.create({
alt: { default: "" }, alt: { default: "" },
caption: { default: "" }, caption: { default: "" },
placement: { default: "full-width" }, placement: { default: "full-width" },
width: { default: null },
}; };
}, },
@@ -23,10 +25,11 @@ export const Figure = Node.create({
}, },
renderHTML({ HTMLAttributes }) { 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 [ return [
"figure", "figure",
mergeAttributes({ "data-figure": "", "data-placement": placement }), mergeAttributes({ "data-figure": "", "data-placement": placement, style }),
["img", { src, alt }], ["img", { src, alt }],
["figcaption", {}, caption ?? ""], ["figcaption", {}, caption ?? ""],
]; ];
+6 -3
View File
@@ -1,6 +1,6 @@
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { invoke } from "@tauri-apps/api/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 { export interface EpubFile {
path: string; path: string;
@@ -72,7 +72,10 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
const caption = node.attrs?.caption const caption = node.attrs?.caption
? `<figcaption>${esc(node.attrs.caption)}</figcaption>` ? `<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 { function block(node: JSONContent, paths: Map<string, string>): string {
@@ -439,7 +442,7 @@ hr.scene-break::before {
} }
figure { figure {
margin: 1.4em 0; margin: 1.4em auto;
text-align: center; 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 { 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"; import type { ImageInput } from "../ipc";
const TRIM: Record<TrimSize, { w: string; h: string }> = { 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") { if (placement === "full-page") {
return `#page(margin: 0pt, numbering: none)[#image(${str(path)}, width: 100%, height: 100%, fit: "cover")]`; 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 caption = node.attrs?.caption ? `, caption: [${esc(node.attrs.caption)}]` : "";
const float = placement === "float-top" ? ", placement: top" : ""; 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 { 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) pagebreak(weak: true)
[#metadata(info) <chap>]
v(2.1in) v(2.1in)
align(center)[ align(center)[
#text(font: "Hanken Grotesk", size: 8.5pt, weight: "semibold", tracking: 0.28em)[#upper("Chapter " + num)] #if info.kind == "body" {
#v(0.7em) text(font: "Hanken Grotesk", size: 8.5pt, weight: "semibold", tracking: 0.28em)[#upper("Chapter " + info.num)]
#heading(level: 1, numbering: none, outlined: true)[#title] v(0.7em)
}
#heading(level: 1, numbering: none, outlined: info.toc)[#info.title]
] ]
v(1.5em) 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) pagebreak(weak: true)
v(2.1in) set par(justify: false, first-line-indent: 0pt)
align(center)[ block(below: 2em)[#text(font: "Literata", size: 24pt, weight: "medium")[Contents]]
#heading(level: 1, numbering: none, outlined: true)[#title] context {
] let items = query(<chap>).filter(it => it.value.toc != false)
v(1.5em) 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)); 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 { export function bookToTypst(book: Book, paths: Map<string, string> = new Map(), coverPath?: string): string {
const meta = book.metadata; const meta = book.metadata;
const cover = `${coverBlock(book, coverPath)}\n#pagebreak(weak: true)`; const cover = `${coverBlock(book, coverPath)}\n#pagebreak(weak: true)`;
const front = `#set page(numbering: none) const front = `#set page(numbering: none)
#titlepage(${str(meta.title || "Untitled")}, ${str(meta.subtitle)}, ${str(meta.author)}) #titlepage(${str(meta.title || "Untitled")}, ${str(meta.subtitle)}, ${str(meta.author)})
#pagebreak(weak: true) #pagebreak(weak: true)
#outline(title: [Contents], depth: 1) #set page(numbering: "i")
#set page(numbering: "1") #counter(page).update(1)
#counter(page).update(1)`; #contents()`;
const firstBody = book.chapters.findIndex((c) => chapterKind(c) === "body");
const body = book.chapters const body = book.chapters
.map((chapter, i) => { .map((chapter, i) => {
const opener = const reset = i === firstBody ? `#set page(numbering: "1")\n#counter(page).update(1)\n` : "";
chapterKind(chapter) === "body" return `${reset}${openerCall(book, i)}\n\n${chapterBody(chapter.content, paths)}`;
? `#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"); .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 { 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)} return `${preamble(book)}
#set page(numbering: "1") #set page(numbering: "1")
#counter(page).update(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)); .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( function readNavTitles(
manifest: Map<string, ManifestItem>, manifest: Map<string, ManifestItem>,
byPath: Map<string, RawFile>, byPath: Map<string, RawFile>,
@@ -284,9 +291,9 @@ function readNavTitles(
const doc = parseXml(file.data, "text/html"); const doc = parseXml(file.data, "text/html");
const baseDir = dirOf(nav.href); const baseDir = dirOf(nav.href);
doc.querySelectorAll("a[href]").forEach((a) => { doc.querySelectorAll("a[href]").forEach((a) => {
const href = a.getAttribute("href")!; const key = resolvePath(baseDir, decodeHref(a.getAttribute("href")!));
const label = a.textContent?.trim(); const label = cleanLabel(a.textContent);
if (label) titles.set(resolvePath(baseDir, decodeHref(href)), label); if (label && !titles.has(key)) titles.set(key, label);
}); });
} }
} }
@@ -298,9 +305,12 @@ function readNavTitles(
Array.from(doc.getElementsByTagName("*")) Array.from(doc.getElementsByTagName("*"))
.filter((el) => el.localName === "navPoint") .filter((el) => el.localName === "navPoint")
.forEach((point) => { .forEach((point) => {
const label = firstByLocal(point, "text")?.textContent?.trim(); const label = cleanLabel(firstByLocal(point, "text")?.textContent);
const src = firstByLocal(point, "content")?.getAttribute("src"); 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 TrimSize = "6x9" | "5.5x8.5" | "5x8" | "a5";
export type FigurePlacement = "inline" | "full-width" | "full-page" | "float-top"; 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 { export interface BookMetadata {
title: string; title: string;
subtitle: string; subtitle: string;
+18 -5
View File
@@ -4,6 +4,7 @@ import {
type Book, type Book,
type BookMetadata, type BookMetadata,
type BookSettings, type BookSettings,
type ChapterKind,
type Cover, type Cover,
chapterKind, chapterKind,
createChapter, createChapter,
@@ -17,6 +18,10 @@ interface BookState {
book: Book | null; book: Book | null;
activeChapterId: string; activeChapterId: string;
dirty: boolean; dirty: boolean;
exporting: string | null;
notice: string | null;
setExporting: (label: string | null) => void;
setNotice: (message: string | null) => void;
openBook: (book: Book) => void; openBook: (book: Book) => void;
closeBook: () => void; closeBook: () => void;
setActiveChapter: (id: string) => void; setActiveChapter: (id: string) => void;
@@ -25,7 +30,7 @@ interface BookState {
addChapter: () => void; addChapter: () => void;
addPage: (group: "front" | "back", title: string) => void; addPage: (group: "front" | "back", title: string) => void;
deleteChapter: (id: 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; setMetadata: (patch: Partial<BookMetadata>) => void;
setSettings: (patch: Partial<BookSettings>) => void; setSettings: (patch: Partial<BookSettings>) => void;
setCover: (patch: Partial<Cover>) => void; setCover: (patch: Partial<Cover>) => void;
@@ -36,6 +41,10 @@ export const useBook = create<BookState>((set) => ({
book: null, book: null,
activeChapterId: "", activeChapterId: "",
dirty: false, dirty: false,
exporting: null,
notice: null,
setExporting: (label) => set({ exporting: label }),
setNotice: (message) => set({ notice: message }),
openBook: (book) => { openBook: (book) => {
const normalized = normalizeBook(book); const normalized = normalizeBook(book);
set({ book: normalized, activeChapterId: normalized.chapters[0]?.id ?? "", dirty: false }); 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; s.activeChapterId === id ? (chapters[index] ?? chapters[index - 1])?.id ?? COVER_ID : s.activeChapterId;
return { dirty: true, activeChapterId, book: { ...s.book, chapters } }; return { dirty: true, activeChapterId, book: { ...s.book, chapters } };
}), }),
moveChapter: (from, to) => moveChapter: (from, to, toKind) =>
set((s) => { set((s) => {
if (!s.book || from === to) return {}; if (!s.book) return {};
const chapters = [...s.book.chapters]; 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); 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 } }; return { dirty: true, book: { ...s.book, chapters } };
}), }),
setMetadata: (patch) => setMetadata: (patch) =>
+85 -2
View File
@@ -106,6 +106,8 @@ button {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 6px 0 10px; padding: 6px 0 10px;
user-select: none;
-webkit-user-select: none;
} }
.brand { .brand {
@@ -322,10 +324,12 @@ button {
} }
.add-chapter { .add-chapter {
margin: 8px 18px 4px; margin: 8px 10px 4px;
width: calc(100% - 20px);
padding: 9px 12px; padding: 9px 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
gap: 8px; gap: 8px;
border-radius: var(--r-sm); border-radius: var(--r-sm);
color: var(--ink-faint); color: var(--ink-faint);
@@ -739,7 +743,7 @@ button {
} }
.figure { .figure {
margin: 1.8rem 0; margin: 1.8rem auto;
} }
.figure img { .figure img {
@@ -760,11 +764,25 @@ button {
} }
.figure-frame { .figure-frame {
position: relative;
background: var(--sidebar); background: var(--sidebar);
border-radius: 3px; border-radius: 3px;
overflow: hidden; 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 { .figure-empty {
aspect-ratio: 3 / 2; aspect-ratio: 3 / 2;
display: grid; display: grid;
@@ -800,12 +818,15 @@ button {
.figure-controls { .figure-controls {
display: flex; display: flex;
flex-wrap: nowrap;
gap: 4px; gap: 4px;
justify-content: center; justify-content: center;
margin-top: 9px; margin-top: 9px;
} }
.figure-controls button { .figure-controls button {
flex-shrink: 0;
white-space: nowrap;
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--t-1); font-size: var(--t-1);
padding: 5px 10px; padding: 5px 10px;
@@ -820,6 +841,15 @@ button {
color: var(--accent-contrast); 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 { .pdf-pages {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1003,6 +1033,42 @@ button {
color: var(--ink); 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 { .toast {
position: fixed; position: fixed;
bottom: 26px; bottom: 26px;
@@ -1497,3 +1563,20 @@ button {
.nav-section + .nav-section { .nav-section + .nav-section {
margin-top: 6px; 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);
}