changes
No files matched your search
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 972 B After Width: | Height: | Size: 1017 B |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ?? ""],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||