This commit is contained in:
pj committed 2026-06-27 09:32:22 -04:00
1 parent 11df8697b1
commit c090fb595f
61 files changed
+52534 -107

No files matched your search

+6 -1
View File
@@ -4,8 +4,10 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
import { Library } from "./components/Library";
import { EditorView } from "./components/EditorView";
import { BackupSettings } from "./components/BackupSettings";
import { ExportPreview } from "./components/ExportPreview";
import { useBook } from "./store/useBook";
import { useBackup } from "./store/useBackup";
import { useExportPreview } from "./store/useExportPreview";
import { isDesktop } from "./ipc";
import { createAndOpenBook, saveBook } from "./library";
import { runExport } from "./export/run";
@@ -21,7 +23,9 @@ function App() {
const unlisten = listen<string>("menu-action", (event) => {
const state = useBook.getState();
if (event.payload === "new-book") createAndOpenBook(state.openBook, state.setNotice);
else if (event.payload === "export-pdf") runExport("pdf");
else if (event.payload === "export-pdf") {
if (useBook.getState().book) useExportPreview.getState().openPreview();
}
else if (event.payload === "export-epub") runExport("epub");
else if (event.payload === "check-updates") checkForUpdates(false);
});
@@ -82,6 +86,7 @@ function App() {
<>
{book ? <EditorView /> : <Library onOpen={openBook} />}
{isDesktop && <BackupSettings />}
{isDesktop && <ExportPreview />}
</>
);
}
+154 -57
View File
@@ -14,6 +14,7 @@ import { FloatingToolbar } from "../editor/FloatingToolbar";
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
import { COVER_ID, useBook } from "../store/useBook";
import { useBackup } from "../store/useBackup";
import { useExportPreview } from "../store/useExportPreview";
import { useProofing } from "../store/useProofing";
import { useTheme } from "../store/useTheme";
import { useWidth } from "../store/useWidth";
@@ -21,6 +22,7 @@ import { WIDTH_OPTIONS } from "../width";
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
import { saveBook } from "../library";
import { isDesktop } from "../ipc";
import { useCompact } from "../useMedia";
import { issueSignature, rememberWord, runProof } from "../proofing";
import { runExport } from "../export/run";
@@ -40,10 +42,13 @@ export function EditorView() {
const width = useWidth((s) => s.width);
const setWidth = useWidth((s) => s.setWidth);
const isCompact = useCompact();
const [editor, setEditor] = useState<TiptapEditor | null>(null);
const [dock, setDock] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
const [dock, setDock] = useState(!isCompact);
const [settingsOpen, setSettingsOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const [widthOpen, setWidthOpen] = useState(false);
const [findOpen, setFindOpen] = useState(false);
const [findExpanded, setFindExpanded] = useState(false);
@@ -58,6 +63,21 @@ export function EditorView() {
const coverActive = activeChapterId === COVER_ID;
const proofingAvailable = isDesktop && book?.metadata.language === "en";
const toggleSidebar = () =>
setSidebarOpen((open) => {
if (!open && isCompact) setDock(false);
return !open;
});
const toggleDock = () =>
setDock((open) => {
if (!open && isCompact) setSidebarOpen(false);
return !open;
});
const closeDrawers = () => {
setSidebarOpen(false);
setDock(false);
};
const openFind = useCallback((expanded: boolean) => {
setFindExpanded(expanded);
setFindOpen(true);
@@ -112,6 +132,11 @@ export function EditorView() {
return () => clearTimeout(timer);
}, [notice]);
useEffect(() => {
setSidebarOpen(!isCompact);
setDock(!isCompact);
}, [isCompact]);
useEffect(() => {
if (!editor) return;
const storage = (editor.storage as unknown as Record<string, ProofingStorage>).proofing;
@@ -147,7 +172,8 @@ export function EditorView() {
const handleExport = (format: "pdf" | "epub") => {
setExportOpen(false);
runExport(format);
if (format === "pdf" && isDesktop) useExportPreview.getState().openPreview();
else runExport(format);
};
if (!book) return null;
@@ -165,18 +191,23 @@ export function EditorView() {
: "Back matter";
return (
<div className="app">
<div className="app" data-compact={isCompact} data-sidebar={sidebarOpen} data-dock={dock}>
<header className="titlebar" data-tauri-drag-region>
<div className="lead">
<button className="icon-btn" data-on={sidebarOpen} onClick={toggleSidebar} title="Toggle chapters" aria-label="Toggle chapters">
<Icon d="M3 4.5h18v15H3zM9 4.5v15" />
</button>
</div>
<button className="doc-title" onClick={() => setSettingsOpen(true)} title="Book setup">
{book.metadata.title || "Untitled"}
{dirty && <span className="dirty-dot" />}
</button>
<div className="actions">
{isDesktop && <BackupButton />}
{isDesktop && !isCompact && <BackupButton />}
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
</button>
{proofingAvailable && !coverActive && (
{!isCompact && proofingAvailable && !coverActive && (
<>
<button className="icon-btn" data-on={spelling} onClick={toggleSpelling} title="Check spelling">
<Icon d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" />
@@ -186,57 +217,120 @@ export function EditorView() {
</button>
</>
)}
<div className="menu-wrap">
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
{!isCompact && (
<div className="menu-wrap">
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
</button>
{widthOpen && (
<>
<div className="menu-backdrop" onClick={() => setWidthOpen(false)} />
<div className="menu">
{WIDTH_OPTIONS.map((w) => (
<button
key={w.id}
data-on={width === w.id}
onClick={() => {
setWidth(w.id);
setWidthOpen(false);
}}
>
{w.label}
</button>
))}
</div>
</>
)}
</div>
)}
{!isCompact && (
<div className="menu-wrap">
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
<Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" />
</button>
{exportOpen && (
<>
<div className="menu-backdrop" onClick={() => setExportOpen(false)} />
<div className="menu">
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
</div>
</>
)}
</div>
)}
{!isCompact && (
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}>
{theme === "dark" ? (
<Icon>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4" />
</Icon>
) : (
<Icon d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
)}
</button>
{widthOpen && (
<>
<div className="menu-backdrop" onClick={() => setWidthOpen(false)} />
<div className="menu">
{WIDTH_OPTIONS.map((w) => (
<button
key={w.id}
data-on={width === w.id}
onClick={() => {
setWidth(w.id);
setWidthOpen(false);
}}
>
{w.label}
</button>
))}
</div>
</>
)}
</div>
<div className="menu-wrap">
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
<Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" />
</button>
{exportOpen && (
<>
<div className="menu-backdrop" onClick={() => setExportOpen(false)} />
<div className="menu">
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
</div>
</>
)}
</div>
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}>
{theme === "dark" ? (
<Icon>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4" />
</Icon>
) : (
<Icon d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
)}
</button>
<button className="icon-btn" data-on={dock} onClick={() => setDock(!dock)} title="Toggle preview">
)}
<button className="icon-btn" data-on={dock} onClick={toggleDock} title="Toggle preview">
<Icon d="M3 4.5h18v15H3zM14 4.5v15" />
</button>
{isCompact && (
<div className="menu-wrap">
<button className="icon-btn" data-on={moreOpen} onClick={() => setMoreOpen((v) => !v)} title="More" aria-label="More options">
<Icon d="M5 12h.01M12 12h.01M19 12h.01" />
</button>
{moreOpen && (
<>
<div className="menu-backdrop" onClick={() => setMoreOpen(false)} />
<div className="menu">
<div className="menu-label">Editor width</div>
{WIDTH_OPTIONS.map((w) => (
<button
key={w.id}
data-on={width === w.id}
onClick={() => {
setWidth(w.id);
setMoreOpen(false);
}}
>
{w.label}
</button>
))}
<div className="menu-sep" />
<button
onClick={() => {
setMoreOpen(false);
handleExport("pdf");
}}
>
Export PDF…
</button>
<button
onClick={() => {
setMoreOpen(false);
handleExport("epub");
}}
>
Export EPUB…
</button>
{proofingAvailable && !coverActive && (
<>
<div className="menu-sep" />
<button data-on={spelling} onClick={toggleSpelling}>
Check spelling
</button>
<button data-on={grammar} onClick={toggleGrammar}>
Check grammar
</button>
</>
)}
<div className="menu-sep" />
<button onClick={toggleTheme}>{theme === "dark" ? "Light mode" : "Dark mode"}</button>
</div>
</>
)}
</div>
)}
</div>
</header>
@@ -248,8 +342,8 @@ export function EditorView() {
/>
<div className="body">
<Sidebar />
<ResizeHandle pane="sidebar" />
<Sidebar onNavigate={() => isCompact && setSidebarOpen(false)} />
{sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />}
<main className="editor-pane">
{coverActive ? (
<CoverView />
@@ -259,7 +353,7 @@ export function EditorView() {
<header className="chapter-opener">
<div className="chapter-num">{eyebrow}</div>
<ChapterTitleInput
value={chapter.noTitle ? "" : chapter.title}
value={chapter.title}
placeholder={
chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
}
@@ -293,8 +387,11 @@ export function EditorView() {
</>
)}
</main>
{dock && <ResizeHandle pane="dock" />}
{dock && !isCompact && <ResizeHandle pane="dock" />}
{dock && <Dock />}
{isCompact && (sidebarOpen || dock) && (
<button className="drawer-scrim" aria-label="Close panel" onClick={closeDrawers} />
)}
</div>
{proofPopover && editor && (
+339
View File
@@ -0,0 +1,339 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import * as pdfjs from "pdfjs-dist";
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import { useBook } from "../store/useBook";
import { useExportPreview } from "../store/useExportPreview";
import { bookToPdfInputs, unsupportedScripts } from "../export/typst";
import { compilePdf } from "../ipc";
import { saveBytes } from "../project";
import { Icon } from "./Icon";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
const PDF_FILTER = [{ name: "PDF", extensions: ["pdf"] }];
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 2.5;
const ZOOM_STEP = 0.25;
interface Frame {
left: number;
top: number;
width: number;
height: number;
}
function measureEditorPane(): Frame | null {
const pane = document.querySelector(".editor-pane");
if (!pane) return null;
const r = pane.getBoundingClientRect();
return {
left: Math.round(r.left),
top: Math.round(r.top),
width: Math.round(r.width),
height: Math.round(r.height),
};
}
export function ExportPreview() {
const open = useExportPreview((s) => s.open);
const book = useBook((s) => s.book);
if (!open || !book) return null;
return <ExportPreviewModal />;
}
function ExportPreviewModal() {
const book = useBook((s) => s.book)!;
const close = useExportPreview((s) => s.close);
const [pdf, setPdf] = useState<Uint8Array | null>(null);
const [error, setError] = useState<string | null>(null);
const [pages, setPages] = useState(0);
const [zoom, setZoom] = useState(1);
const [saving, setSaving] = useState(false);
const [frame, setFrame] = useState<Frame | null>(measureEditorPane);
const panelRef = useRef<HTMLDivElement>(null);
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
useLayoutEffect(() => {
const update = () => setFrame(measureEditorPane());
update();
const pane = document.querySelector(".editor-pane");
const observer = new ResizeObserver(update);
if (pane) observer.observe(pane);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("resize", update);
};
}, []);
useEffect(() => {
let cancelled = false;
setPdf(null);
setError(null);
const { source, images, fonts } = bookToPdfInputs(book);
compilePdf(source, images, false, fonts)
.then((bytes) => !cancelled && setPdf(bytes))
.catch((e) => !cancelled && setError(String(e)));
return () => {
cancelled = true;
};
}, [book]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [close]);
useEffect(() => {
const el = panelRef.current;
if (!el) return;
const clamp = (z: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
const snap = (z: number) => clamp(Math.round(z / ZOOM_STEP) * ZOOM_STEP);
let start = 1;
let accum = 0;
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return;
e.preventDefault();
accum += e.deltaY;
if (accum <= -40) {
accum = 0;
setZoom((z) => clamp(z + ZOOM_STEP));
} else if (accum >= 40) {
accum = 0;
setZoom((z) => clamp(z - ZOOM_STEP));
}
};
const onGestureStart = (e: Event) => {
e.preventDefault();
start = zoomRef.current;
};
const onGestureChange = (e: Event) => {
e.preventDefault();
const next = snap(start * (e as unknown as { scale: number }).scale);
setZoom((z) => (z === next ? z : next));
};
const onGestureEnd = (e: Event) => e.preventDefault();
el.addEventListener("wheel", onWheel, { passive: false });
el.addEventListener("gesturestart", onGestureStart);
el.addEventListener("gesturechange", onGestureChange);
el.addEventListener("gestureend", onGestureEnd);
return () => {
el.removeEventListener("wheel", onWheel);
el.removeEventListener("gesturestart", onGestureStart);
el.removeEventListener("gesturechange", onGestureChange);
el.removeEventListener("gestureend", onGestureEnd);
};
}, []);
const scripts = unsupportedScripts(book);
const save = async () => {
if (!pdf) return;
setSaving(true);
try {
await saveBytes(pdf, `${book.metadata.title || "Untitled"}.pdf`, PDF_FILTER);
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
const adjustZoom = (delta: number) =>
setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round((z + delta) * 100) / 100)));
const compact = (frame?.width ?? 1000) < 460;
return (
<div className="overlay preview-overlay" onClick={close}>
<div
ref={panelRef}
className="panel preview-panel"
style={frame ? { position: "fixed", left: frame.left, top: frame.top, width: frame.width, height: frame.height } : undefined}
onClick={(e) => e.stopPropagation()}
>
<header className="preview-bar">
<button className="icon-btn" onClick={close} title="Close (Esc)">
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
<div className="preview-title">{book.metadata.title || "Untitled"}</div>
{pdf && !compact && (
<span className="preview-count">
{pages} {pages === 1 ? "page" : "pages"}
</span>
)}
<div className="preview-zoom">
<button className="icon-btn" disabled={!pdf || zoom <= ZOOM_MIN} onClick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out">
<Icon d="M5 12h14" />
</button>
{!compact && <span>{Math.round(zoom * 100)}%</span>}
<button className="icon-btn" disabled={!pdf || zoom >= ZOOM_MAX} onClick={() => adjustZoom(ZOOM_STEP)} title="Zoom in">
<Icon d="M12 5v14M5 12h14" />
</button>
</div>
<button className="btn-primary" disabled={!pdf || saving} onClick={save}>
{saving ? "Saving…" : compact ? "Save" : "Save PDF…"}
</button>
</header>
{pdf && scripts.length > 0 && (
<div className="preview-warn">
Some {scripts.join(", ")} characters may not render — the PDF fonts cover Latin scripts only.
</div>
)}
{error ? (
<div className="preview-stage">
<pre className="dock-error">{error}</pre>
</div>
) : pdf ? (
<BookPages data={pdf} zoom={zoom} onPages={setPages} />
) : (
<div className="preview-stage">
<div className="preview-loading">
<div className="spinner spinner-dark" />
<p>Typesetting your book…</p>
</div>
</div>
)}
</div>
</div>
);
}
function BookPages({ data, zoom, onPages }: { data: Uint8Array; zoom: number; onPages: (n: number) => void }) {
const stageRef = useRef<HTMLDivElement>(null);
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
const [baseRatio, setBaseRatio] = useState<number | null>(null);
const [stageWidth, setStageWidth] = useState(0);
useEffect(() => {
let cancelled = false;
const task = pdfjs.getDocument({ data: data.slice() });
task.promise
.then(async (d) => {
if (cancelled) return;
const first = (await d.getPage(1)).getViewport({ scale: 1 });
if (cancelled) return;
setDoc(d);
setBaseRatio(first.height / first.width);
onPages(d.numPages);
})
.catch(() => {});
return () => {
cancelled = true;
task.destroy();
};
}, [data, onPages]);
useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const update = () => setStageWidth(stage.clientWidth);
update();
const observer = new ResizeObserver(update);
observer.observe(stage);
return () => observer.disconnect();
}, [doc]);
const fit = Math.max(280, stageWidth - 56);
const displayWidth = Math.round(fit * zoom);
return (
<div className="preview-stage" ref={stageRef}>
{doc && baseRatio !== null && stageWidth > 0 && (
<div className="preview-col">
{Array.from({ length: doc.numPages }, (_, i) => (
<PdfPage
key={i + 1}
doc={doc}
pageNumber={i + 1}
root={stageRef.current}
displayWidth={displayWidth}
baseRatio={baseRatio}
/>
))}
</div>
)}
</div>
);
}
function PdfPage({
doc,
pageNumber,
root,
displayWidth,
baseRatio,
}: {
doc: PDFDocumentProxy;
pageNumber: number;
root: HTMLElement | null;
displayWidth: number;
baseRatio: number;
}) {
const holder = useRef<HTMLDivElement>(null);
const [show, setShow] = useState(false);
const [ratio, setRatio] = useState(baseRatio);
useEffect(() => {
const el = holder.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => setShow(entry.isIntersecting), {
root,
rootMargin: "1400px 0px",
});
observer.observe(el);
return () => observer.disconnect();
}, [root]);
useEffect(() => {
const el = holder.current;
if (!el) return;
if (!show) {
el.replaceChildren();
return;
}
let cancelled = false;
let task: RenderTask | undefined;
(async () => {
try {
const page = await doc.getPage(pageNumber);
if (cancelled) return;
const natural = page.getViewport({ scale: 1 });
setRatio(natural.height / natural.width);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const viewport = page.getViewport({ scale: (displayWidth / natural.width) * dpr });
const canvas = document.createElement("canvas");
canvas.className = "preview-canvas";
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
task = page.render({ canvas, viewport });
await task.promise;
if (cancelled) return;
el.replaceChildren(canvas);
} catch {
// render cancelled or page failed; keep the previous canvas
}
})();
return () => {
cancelled = true;
task?.cancel();
};
}, [show, doc, pageNumber, displayWidth]);
return (
<div
ref={holder}
className="preview-page"
style={{ width: displayWidth, height: Math.round(displayWidth * ratio) }}
/>
);
}
+37 -1
View File
@@ -6,11 +6,15 @@ interface RowMenuProps {
label: string;
onDuplicate?: () => void;
onDelete: () => void;
onToggleTitle?: () => void;
titleHidden?: boolean;
onToggleMargin?: () => void;
marginHidden?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
export function RowMenu({ label, onDuplicate, onDelete, onOpenChange, className = "" }: RowMenuProps) {
export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidden, onToggleMargin, marginHidden, onOpenChange, className = "" }: RowMenuProps) {
const [open, setOpen] = useState(false);
useEffect(() => {
@@ -46,6 +50,18 @@ export function RowMenu({ label, onDuplicate, onDelete, onOpenChange, className
};
}, [open]);
const toggleTitle = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
onToggleTitle?.();
};
const toggleMargin = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
onToggleMargin?.();
};
const duplicate = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
@@ -73,6 +89,26 @@ export function RowMenu({ label, onDuplicate, onDelete, onOpenChange, className
{open &&
createPortal(
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }}>
{onToggleTitle && (
<button className="row-menu-item" onClick={toggleTitle}>
{titleHidden ? (
<Icon d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" size={14} />
) : (
<Icon d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24M1 1l22 22" size={14} />
)}
{titleHidden ? "Show title" : "Hide title"}
</button>
)}
{onToggleMargin && (
<button className="row-menu-item" onClick={toggleMargin}>
{marginHidden ? (
<Icon d="M4 5h16 M12 10v10 M8 16l4 4 4-4" size={14} />
) : (
<Icon d="M4 5h16 M12 20V10 M8 14l4-4 4 4" size={14} />
)}
{marginHidden ? "Show top margin" : "Hide top margin"}
</button>
)}
{onDuplicate && (
<button className="row-menu-item" onClick={duplicate}>
<Icon d="M9 9h11v11h-11z M6 15V5h9" size={14} />
+12 -2
View File
@@ -39,7 +39,7 @@ function formatEdited(ts: number, now: number): string {
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
export function Sidebar() {
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const chapters = useBook((s) => s.book?.chapters ?? []);
const activeChapterId = useBook((s) => s.activeChapterId);
const setActiveChapter = useBook((s) => s.setActiveChapter);
@@ -47,6 +47,8 @@ export function Sidebar() {
const addPage = useBook((s) => s.addPage);
const addPart = useBook((s) => s.addPart);
const duplicateChapter = useBook((s) => s.duplicateChapter);
const setChapterNoTitle = useBook((s) => s.setChapterNoTitle);
const setChapterNoMargin = useBook((s) => s.setChapterNoMargin);
const deleteChapter = useBook((s) => s.deleteChapter);
const moveChapter = useBook((s) => s.moveChapter);
const closeBook = useBook((s) => s.closeBook);
@@ -150,6 +152,7 @@ export function Sidebar() {
return;
}
setActiveChapter(id);
onNavigate?.();
};
return (
@@ -161,7 +164,10 @@ export function Sidebar() {
<button
className="cover-item"
data-active={activeChapterId === COVER_ID}
onClick={() => setActiveChapter(COVER_ID)}
onClick={() => {
setActiveChapter(COVER_ID);
onNavigate?.();
}}
>
<Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} />
<span className="title">Cover</span>
@@ -224,6 +230,10 @@ export function Sidebar() {
onOpenChange={(open) =>
setMenuOpenId((cur) => (open ? row.chapter.id : cur === row.chapter.id ? null : cur))
}
titleHidden={!!row.chapter.noTitle}
onToggleTitle={() => setChapterNoTitle(row.chapter.id, !row.chapter.noTitle)}
marginHidden={!!row.chapter.noMargin}
onToggleMargin={() => setChapterNoMargin(row.chapter.id, !row.chapter.noMargin)}
onDuplicate={() => duplicateChapter(row.chapter.id)}
onDelete={() => setPendingDelete({ id: row.chapter.id, title: rowLabel })}
/>
+37
View File
@@ -1,6 +1,13 @@
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
import type { Editor } from "@tiptap/react";
import { Icon } from "../components/Icon";
import type { Alignment } from "./align";
const ALIGN_ICONS: Record<Alignment, string> = {
left: "M4 7h16M4 12h10M4 17h13",
center: "M4 7h16M7 12h10M5 17h14",
right: "M4 7h16M10 12h10M7 17h13",
};
function readImage(file: File): Promise<string> {
return new Promise((resolve, reject) => {
@@ -23,6 +30,7 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
const linkInputRef = useRef<HTMLInputElement>(null);
const [linkOpen, setLinkOpen] = useState(false);
const [linkValue, setLinkValue] = useState("");
const [alignOpen, setAlignOpen] = useState(false);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() => {
@@ -67,6 +75,13 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
setLinkOpen(false);
};
const align: Alignment = (editor.getAttributes("heading").align ?? editor.getAttributes("paragraph").align) || "left";
const setAlign = (value: Alignment) => {
editor.chain().focus().setTextAlign(value).run();
setAlignOpen(false);
};
const tool = (active: boolean, onClick: () => void, title: string, content: ReactNode) => (
<button
className="tool"
@@ -87,6 +102,28 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
{tool(editor.isActive("heading", { level: 2 }), () => editor.chain().focus().toggleHeading({ level: 2 }).run(), "Heading", <Icon d="M5 5v14M5 12h8M13 5v14" />)}
{tool(editor.isActive("blockquote"), () => editor.chain().focus().toggleBlockquote().run(), "Quote", <Icon d="M7 8h4v4a4 4 0 0 1-4 4M14 8h4v4a4 4 0 0 1-4 4" />)}
{tool(editor.isActive("bulletList"), () => editor.chain().focus().toggleBulletList().run(), "Bulleted list", <Icon d="M8 6h12M8 12h12M8 18h12M3.5 6h.01M3.5 12h.01M3.5 18h.01" />)}
<span className="tool-wrap">
{tool(alignOpen || align !== "left", () => setAlignOpen((v) => !v), "Align", <Icon d={ALIGN_ICONS[align]} />)}
{alignOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setAlignOpen(false)} />
<div className="align-pop" onMouseDown={(e) => e.stopPropagation()}>
{(["left", "center", "right"] as Alignment[]).map((value) => (
<button
key={value}
className="tool"
data-on={align === value}
title={`Align ${value}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setAlign(value)}
>
<Icon d={ALIGN_ICONS[value]} />
</button>
))}
</div>
</>
)}
</span>
<span className="tool-sep" />
<span className="tool-wrap">
{tool(editor.isActive("link") || linkOpen, () => (linkOpen ? setLinkOpen(false) : openLink()), "Link", <Icon d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" />)}
+64
View File
@@ -0,0 +1,64 @@
import { Extension } from "@tiptap/core";
export type Alignment = "left" | "center" | "right";
const ALIGNED_TYPES = ["paragraph", "heading"];
declare module "@tiptap/core" {
interface Commands<ReturnType> {
textAlign: {
setTextAlign: (align: Alignment) => ReturnType;
};
}
}
export const TextAlign = Extension.create({
name: "textAlign",
addGlobalAttributes() {
return [
{
types: ALIGNED_TYPES,
attributes: {
align: {
default: null,
keepOnSplit: true,
parseHTML: (el) => {
const value = el.style.textAlign || el.getAttribute("data-align");
return value === "center" || value === "right" ? value : null;
},
renderHTML: (attrs) =>
attrs.align ? { "data-align": attrs.align, style: `text-align: ${attrs.align}` } : {},
},
},
},
];
},
addCommands() {
return {
setTextAlign:
(align) =>
({ tr, dispatch }) => {
const value = align === "left" ? null : align;
const { from, to } = tr.selection;
let changed = false;
tr.doc.nodesBetween(from, to, (node, pos) => {
if (ALIGNED_TYPES.includes(node.type.name) && node.attrs.align !== value) {
if (dispatch) tr.setNodeAttribute(pos, "align", value);
changed = true;
}
});
return changed;
},
};
},
addKeyboardShortcuts() {
return {
"Mod-Shift-l": () => this.editor.commands.setTextAlign("left"),
"Mod-Shift-e": () => this.editor.commands.setTextAlign("center"),
"Mod-Shift-r": () => this.editor.commands.setTextAlign("right"),
};
},
});
+4
View File
@@ -3,8 +3,10 @@ import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { Figure } from "./figure";
import { ParagraphIndent } from "./indent";
import { TextAlign } from "./align";
import { SearchHighlight } from "./search";
import { Proofing } from "./proofing";
import { Paste } from "./paste";
export const editorExtensions: Extensions = [
StarterKit.configure({
@@ -17,6 +19,8 @@ export const editorExtensions: Extensions = [
}),
Figure,
ParagraphIndent,
TextAlign,
SearchHighlight,
Proofing,
Paste,
];
+76
View File
@@ -0,0 +1,76 @@
import { Extension } from "@tiptap/core";
import type { JSONContent } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
function readImage(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function imageFiles(data: DataTransfer | null): File[] {
if (!data) return [];
const files = Array.from(data.files).filter((f) => f.type.startsWith("image/"));
if (files.length) return files;
return Array.from(data.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((f): f is File => f !== null);
}
function plainContent(text: string): JSONContent[] {
return text
.replace(/\r\n?/g, "\n")
.split(/\n{2,}/)
.map((block) => {
const content: JSONContent[] = [];
block.split("\n").forEach((line, i) => {
if (i > 0) content.push({ type: "hardBreak" });
if (line) content.push({ type: "text", text: line });
});
return content.length ? { type: "paragraph", content } : { type: "paragraph" };
});
}
export const Paste = Extension.create({
name: "pasteHandler",
addKeyboardShortcuts() {
const editor = this.editor;
return {
"Mod-Shift-v": () => {
navigator.clipboard
.readText()
.then((text) => {
if (text) editor.chain().focus().insertContent(plainContent(text)).run();
})
.catch(() => {});
return true;
},
};
},
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
props: {
handlePaste(_view, event) {
const files = imageFiles(event.clipboardData);
if (!files.length) return false;
event.preventDefault();
files.forEach((file) =>
readImage(file).then((src) =>
editor.chain().focus().insertContent({ type: "figure", attrs: { src, placement: "full-width" } }).run(),
),
);
return true;
},
},
}),
];
},
});
+9 -3
View File
@@ -37,10 +37,11 @@ interface ProofingState {
export const proofingKey = new PluginKey<ProofingState>("proofing");
function buildDecorations(doc: PMNode, issues: ProofIssue[]): DecorationSet {
function buildDecorations(doc: PMNode, issues: ProofIssue[], cursor: number | null): DecorationSet {
const size = doc.content.size;
const decos = issues
.filter((i) => i.from < i.to && i.to <= size)
.filter((i) => cursor === null || cursor < i.from || cursor > i.to)
.map((i) => Decoration.inline(i.from, i.to, { class: `proof-mark sev-${severityFor(i.category)}` }));
return DecorationSet.create(doc, decos);
}
@@ -73,9 +74,11 @@ export const Proofing = Extension.create<unknown, ProofingStorage>({
state: {
init: () => ({ issues: [], decorations: DecorationSet.empty }),
apply(tr, value, _old, newState) {
const sel = newState.selection;
const cursor = sel.empty ? sel.head : null;
const meta = tr.getMeta(proofingKey) as { issues: ProofIssue[] } | { clear: true } | undefined;
if (meta && "issues" in meta) {
return { issues: meta.issues, decorations: buildDecorations(newState.doc, meta.issues) };
return { issues: meta.issues, decorations: buildDecorations(newState.doc, meta.issues, cursor) };
}
if (meta && "clear" in meta) {
return { issues: [], decorations: DecorationSet.empty };
@@ -84,7 +87,10 @@ export const Proofing = Extension.create<unknown, ProofingStorage>({
const issues = value.issues
.map((i) => ({ ...i, from: tr.mapping.map(i.from), to: tr.mapping.map(i.to) }))
.filter((i) => i.to > i.from);
return { issues, decorations: value.decorations.map(tr.mapping, tr.doc) };
return { issues, decorations: buildDecorations(newState.doc, issues, cursor) };
}
if (tr.selectionSet && value.issues.length) {
return { issues: value.issues, decorations: buildDecorations(newState.doc, value.issues, cursor) };
}
return value;
},
+10 -4
View File
@@ -132,17 +132,23 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
return `<figure class="placement-${attr(placement)}"${style}><img src="${attr(path)}" alt="${alt}"/>${caption}</figure>`;
}
function alignStyle(align: unknown): string {
return align === "center" || align === "right" ? ` style="text-align:${align}"` : "";
}
function block(node: JSONContent, paths: Map<string, string>): string {
switch (node.type) {
case "paragraph": {
const body = inlines(node.content);
if (!body.trim()) return "<p>&#160;</p>";
const align = node.attrs?.align;
if (align === "center" || align === "right") return `<p${alignStyle(align)}>${body}</p>`;
return `<p${node.attrs?.indent ? ' data-indent="true"' : ""}>${body}</p>`;
}
case "heading":
return node.attrs?.level === 3
? `<h3>${inlines(node.content)}</h3>`
: `<h2>${inlines(node.content)}</h2>`;
case "heading": {
const tag = node.attrs?.level === 3 ? "h3" : "h2";
return `<${tag}${alignStyle(node.attrs?.align)}>${inlines(node.content)}</${tag}>`;
}
case "blockquote":
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
case "bulletList":
+23 -6
View File
@@ -37,6 +37,15 @@ function str(value: string): string {
return JSON.stringify(value);
}
function localeParams(tag: string): string {
const parts = (tag || "en").trim().split(/[-_]/);
const primary = parts[0] ?? "";
const lang = /^[a-zA-Z]{2,3}$/.test(primary) ? primary.toLowerCase() : "en";
const region = parts.slice(1).find((p) => /^[a-zA-Z]{2}$/.test(p));
const params = `lang: ${str(lang)}`;
return region ? `${params}, region: ${str(region.toUpperCase())}` : params;
}
function inline(node: JSONContent, atLineStart: boolean): string {
if (node.type === "text") {
const marks = node.marks ?? [];
@@ -80,15 +89,22 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
}
function aligned(align: unknown, body: string): string {
if (align === "center" || align === "right") return `#align(${align})[#set par(justify: false)\n${body}]`;
return body;
}
function block(node: JSONContent, paths: Map<string, string>): string {
switch (node.type) {
case "paragraph": {
const body = inlines(node.content, true);
if (!body.trim()) return "~";
const align = node.attrs?.align;
if (align === "center" || align === "right") return aligned(align, body);
return node.attrs?.indent ? `#h(1.3em)${body}` : body;
}
case "heading":
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`;
return aligned(node.attrs?.align, `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`);
case "blockquote":
return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`;
case "bulletList":
@@ -124,7 +140,7 @@ function preamble(book: Book): string {
margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}),
binding: left,
)
#set text(font: ${str(body)}, size: 11pt, lang: ${str(meta.language || "en")}, hyphenate: true)
#set text(font: ${str(body)}, size: 11pt, ${localeParams(meta.language)}, hyphenate: true)
#set par(justify: true, leading: 0.72em, spacing: 0.72em)
#show heading: set text(font: ${str(heading)}, weight: "medium")
#show heading.where(level: 1): set text(size: 22pt)
@@ -153,7 +169,7 @@ function preamble(book: Book): string {
#let openchapter(info) = {
pagebreak(weak: true)
[#metadata(info) <chap>]
v(2.1in)
if not info.nomargin { v(2.1in) }
align(center)[
#if info.kind == "body" {
text(font: "Hanken Grotesk", size: 8.5pt, weight: "semibold", tracking: 0.28em)[#upper("Chapter " + info.num)]
@@ -169,7 +185,7 @@ function preamble(book: Book): string {
#let openpart(info) = {
pagebreak(to: "odd", weak: true)
[#metadata(info) <chap>]
v(3.1in)
if not info.nomargin { v(3.1in) }
align(center)[
#text(font: "Hanken Grotesk", size: 10pt, weight: "semibold", tracking: 0.34em)[#upper("Part " + info.numeral)]
#if not info.notitle and info.title != "" {
@@ -340,14 +356,15 @@ function openerCall(book: Book, index: number): string {
const chapter = book.chapters[index];
const kind = chapterKind(chapter);
const notitle = !!chapter.noTitle;
const nomargin = !!chapter.noMargin;
if (kind === "part") {
const numeral = partRoman(partNumber(book.chapters, index) ?? 0);
const title = notitle ? "" : cleanTitle(chapter.title);
return `#openpart((kind: "part", numeral: ${str(numeral)}, title: ${str(title)}, notitle: ${notitle}, toc: ${inToc(book, index)}))`;
return `#openpart((kind: "part", numeral: ${str(numeral)}, title: ${str(title)}, notitle: ${notitle}, nomargin: ${nomargin}, toc: ${inToc(book, index)}))`;
}
const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
const title = notitle ? "" : cleanTitle(chapter.title) || "Untitled";
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, notitle: ${notitle}, toc: ${inToc(book, index)}))`;
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, notitle: ${notitle}, nomargin: ${nomargin}, toc: ${inToc(book, index)}))`;
}
export function bookToTypst(book: Book, paths: Map<string, string> = new Map(), coverPath?: string): string {
+2 -2
View File
@@ -1,7 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import type { JSONContent } from "@tiptap/core";
import { type Book, type BookMetadata, type Chapter, type Cover, createCover } from "../model/book";
import { type Book, type BookMetadata, type Chapter, type Cover, clampLanguage, createCover } from "../model/book";
import { DEFAULT_FONTS } from "../model/fonts";
export interface RawFile {
@@ -508,7 +508,7 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
subtitle: "",
author: localText(opf, "creator"),
isbn: findIsbn(opf),
language: localText(opf, "language") || "en",
language: clampLanguage(localText(opf, "language") || "en"),
};
return {
+10 -1
View File
@@ -73,6 +73,7 @@ export interface Chapter {
updatedAt: number;
kind?: ChapterKind;
noTitle?: boolean;
noMargin?: boolean;
}
export interface PageType {
@@ -182,11 +183,19 @@ export function createCover(): Cover {
const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" };
const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS };
export const SUPPORTED_LANGUAGES = ["en", "es", "fr", "de", "it", "pt"] as const;
export function clampLanguage(tag: string): string {
const primary = (tag || "").trim().split(/[-_]/)[0]?.toLowerCase() ?? "";
return (SUPPORTED_LANGUAGES as readonly string[]).includes(primary) ? primary : "en";
}
export function normalizeBook(book: Book): Book {
const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean);
const metadata = { ...DEFAULT_METADATA, ...book.metadata };
return {
...book,
metadata: { ...DEFAULT_METADATA, ...book.metadata },
metadata: { ...metadata, language: clampLanguage(metadata.language) },
settings: { ...DEFAULT_SETTINGS, ...book.settings, fonts: { ...DEFAULT_FONTS, ...book.settings?.fonts } },
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
chapters: (chapters.length ? chapters : [createChapter()]).map((c) =>
+8 -1
View File
@@ -33,6 +33,7 @@ interface BookState {
setChapterContent: (id: string, content: JSONContent) => void;
setChapterTitle: (id: string, title: string) => void;
setChapterNoTitle: (id: string, noTitle: boolean) => void;
setChapterNoMargin: (id: string, noMargin: boolean) => void;
addChapter: () => void;
addPage: (group: "front" | "back", title: string) => void;
addPart: () => void;
@@ -87,7 +88,13 @@ export const useBook = create<BookState>((set, get) => ({
setChapterNoTitle: (id, noTitle) =>
set((s) =>
s.book
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, noTitle, title: noTitle ? "" : c.title, updatedAt: Date.now() } : c)) } }
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, noTitle, updatedAt: Date.now() } : c)) } }
: {}
),
setChapterNoMargin: (id, noMargin) =>
set((s) =>
s.book
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, noMargin, updatedAt: Date.now() } : c)) } }
: {}
),
addChapter: () =>
+13
View File
@@ -0,0 +1,13 @@
import { create } from "zustand";
interface ExportPreviewState {
open: boolean;
openPreview: () => void;
close: () => void;
}
export const useExportPreview = create<ExportPreviewState>((set) => ({
open: false,
openPreview: () => set({ open: true }),
close: () => set({ open: false }),
}));
+277 -6
View File
@@ -8,8 +8,17 @@ body,
height: 100%;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
position: fixed;
inset: 0;
overflow: hidden;
overscroll-behavior: none;
background: var(--shell);
color: var(--ink);
font-family: var(--font-ui);
@@ -35,12 +44,15 @@ button {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
overflow: hidden;
}
.titlebar {
flex: none;
height: 46px;
position: relative;
z-index: 45;
height: var(--titlebar-h);
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
@@ -49,6 +61,13 @@ button {
border-bottom: 1px solid var(--line);
}
.titlebar .lead {
grid-column: 1;
justify-self: start;
display: flex;
gap: 4px;
}
.titlebar .doc-title {
grid-column: 2;
font-size: var(--t-2);
@@ -150,6 +169,17 @@ body.resizing {
padding: 6px 0 10px;
user-select: none;
-webkit-user-select: none;
transition: width 220ms var(--ease);
}
.app:not([data-compact="true"])[data-sidebar="false"] .sidebar {
width: 0;
border-right-width: 0;
overflow: hidden;
}
body.resizing .sidebar {
transition: none;
}
.brand {
@@ -657,14 +687,14 @@ body.resizing {
display: block;
width: 100%;
margin: 15px 0 0;
padding: 0;
padding: 0 0 0.3em;
border: 0;
background: transparent;
text-align: center;
font-family: var(--font-heading);
font-weight: 500;
font-size: 31px;
line-height: 1.16;
line-height: 1.4;
letter-spacing: -0.01em;
color: var(--ink);
resize: none;
@@ -818,7 +848,7 @@ body.resizing {
.editor-toolbar {
position: sticky;
bottom: 22px;
bottom: max(22px, env(safe-area-inset-bottom));
z-index: 5;
display: flex;
align-items: center;
@@ -891,6 +921,24 @@ body.resizing {
box-shadow: var(--shadow-pop);
}
.align-pop {
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
z-index: 7;
display: flex;
align-items: center;
gap: 1px;
padding: 5px;
background: var(--glass);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border: 1px solid var(--line);
border-radius: 999px;
box-shadow: var(--shadow-pop);
}
.link-input {
width: 220px;
height: 28px;
@@ -1076,6 +1124,122 @@ body.resizing {
padding: 44px 0;
}
.preview-overlay {
z-index: 55;
}
.preview-panel {
display: flex;
flex-direction: column;
}
.preview-bar {
flex: none;
height: 52px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
background: var(--paper);
border-bottom: 1px solid var(--line);
}
.preview-title {
flex: 1;
min-width: 0;
font-family: var(--font-book);
font-size: 15px;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-count {
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--ink-faint);
font-variant-numeric: tabular-nums;
}
.preview-zoom {
display: flex;
align-items: center;
gap: 2px;
}
.preview-zoom span {
width: 44px;
text-align: center;
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
}
.preview-warn {
flex: none;
padding: 9px 18px;
background: var(--accent-wash);
color: var(--accent-ink);
font-family: var(--font-ui);
font-size: var(--t-2);
text-align: center;
border-bottom: 1px solid var(--line);
}
.preview-stage {
flex: 1;
overflow: auto;
padding: 28px;
background: var(--shell);
}
.preview-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: max-content;
min-width: 100%;
margin: 0 auto;
}
.preview-page {
flex: none;
background: #fff;
border-radius: 2px;
box-shadow: var(--shadow-page);
overflow: hidden;
}
.preview-canvas {
display: block;
width: 100%;
height: 100%;
}
.preview-loading {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.preview-loading p {
margin: 0;
font-family: var(--font-ui);
font-size: var(--t-3);
color: var(--ink-soft);
}
.spinner-dark {
border-color: var(--line-strong);
border-top-color: var(--accent);
}
.overlay {
position: fixed;
inset: 0;
@@ -1089,8 +1253,7 @@ body.resizing {
}
.panel {
width: 480px;
max-width: 100%;
width: min(480px, calc(100vw - 32px));
max-height: 100%;
display: flex;
flex-direction: column;
@@ -1231,6 +1394,21 @@ body.resizing {
color: var(--accent);
}
.menu-label {
padding: 7px 11px 3px;
font-size: var(--t-1);
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-faint);
}
.menu-sep {
height: 1px;
margin: 5px 7px;
background: var(--line);
}
.export-overlay {
position: fixed;
inset: 0;
@@ -1286,6 +1464,7 @@ body.resizing {
.library {
height: 100vh;
height: 100dvh;
overflow-y: auto;
background: var(--shell);
}
@@ -2489,3 +2668,95 @@ body.resizing {
font-size: var(--t-2);
line-height: 1.5;
}
/* Compact / mobile layout: panes become slide-in drawers over a full-width editor */
.app[data-compact="true"] {
--titlebar-h: calc(46px + env(safe-area-inset-top));
}
.app[data-compact="true"] .titlebar {
padding: env(safe-area-inset-top) max(12px, env(safe-area-inset-right)) 0 max(12px, env(safe-area-inset-left));
}
.app[data-compact="true"] .sidebar,
.app[data-compact="true"] .dock {
position: fixed;
top: var(--titlebar-h);
bottom: 0;
z-index: 40;
box-shadow: var(--shadow-pop);
}
.app[data-compact="true"] .sidebar {
left: 0;
width: min(82vw, 320px);
padding-left: env(safe-area-inset-left);
border-right: 1px solid var(--line);
transform: translateX(-100%);
transition: transform 260ms var(--ease);
}
.app[data-compact="true"][data-sidebar="true"] .sidebar {
transform: translateX(0);
}
.app[data-compact="true"] .dock {
right: 0;
width: min(92vw, 440px);
padding-right: max(28px, env(safe-area-inset-right));
border-left: 1px solid var(--line);
animation: drawer-in-right 260ms var(--ease);
}
@keyframes drawer-in-right {
from {
transform: translateX(100%);
}
to {
transform: none;
}
}
.drawer-scrim {
position: fixed;
inset: var(--titlebar-h) 0 0 0;
z-index: 35;
background: rgba(0, 0, 0, 0.42);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
animation: scrim-in 200ms var(--ease);
}
@keyframes scrim-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.app[data-compact="true"] .sheet {
padding: 56px 18px 160px;
}
.app[data-compact="true"] .chapter-title {
font-size: 26px;
}
@media (max-width: 899px) {
.overlay {
padding: 16px;
}
.library-head {
padding: max(8px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) 0 max(12px, env(safe-area-inset-left));
}
.shelf {
padding: 20px max(18px, env(safe-area-inset-right)) 60px max(18px, env(safe-area-inset-left));
gap: 18px;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
}
+1
View File
@@ -10,6 +10,7 @@
--pane-sidebar: 248px;
--pane-dock: 384px;
--measure: 46em;
--titlebar-h: 46px;
--t-1: 11px;
--t-2: 12px;
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mq = window.matchMedia(query);
const onChange = () => setMatches(mq.matches);
onChange();
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [query]);
return matches;
}
export const useCompact = () => useMediaQuery("(max-width: 899px)");