mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
changes
This commit is contained in:
1 parent
20ae0b7387
commit
734ded73ac
14 files changed
+874
-24
No files matched your search
@@ -0,0 +1,23 @@
|
|||||||
|
import { type Book, trimRatio } from "../model/book";
|
||||||
|
|
||||||
|
export function CoverArt({ book }: { book: Book }) {
|
||||||
|
const { cover, metadata, settings } = book;
|
||||||
|
const ratio = String(trimRatio(settings.trim));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cover-art" style={{ aspectRatio: ratio }}>
|
||||||
|
{cover.kind === "image" && cover.image ? (
|
||||||
|
<img className="cover-face cover-face-image" src={cover.image} alt={metadata.title || "Cover"} />
|
||||||
|
) : (
|
||||||
|
<div className="cover-face cover-face-default" style={{ background: cover.bg, color: cover.ink }}>
|
||||||
|
<div className="cover-stack">
|
||||||
|
<h1 className="cover-title">{metadata.title || "Untitled"}</h1>
|
||||||
|
<span className="cover-rule" style={{ background: cover.ink }} />
|
||||||
|
{metadata.subtitle && <p className="cover-subtitle">{metadata.subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{metadata.author && <p className="cover-author">{metadata.author}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { COVER_PALETTES } from "../model/book";
|
||||||
|
import { useBook } from "../store/useBook";
|
||||||
|
import { CoverArt } from "./CoverArt";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CoverView() {
|
||||||
|
const book = useBook((s) => s.book);
|
||||||
|
const setCover = useBook((s) => s.setCover);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
if (!book) return null;
|
||||||
|
const cover = book.cover;
|
||||||
|
|
||||||
|
const upload = async (file: File) => {
|
||||||
|
const image = await readImage(file);
|
||||||
|
setCover({ kind: "image", image });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cover-pane">
|
||||||
|
<div className="cover-stage">
|
||||||
|
<CoverArt book={book} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cover-controls">
|
||||||
|
{cover.kind === "image" ? (
|
||||||
|
<>
|
||||||
|
<div className="cover-actions">
|
||||||
|
<button className="cover-btn" onClick={() => fileRef.current?.click()}>
|
||||||
|
<Icon d="M4 5h16v14H4zM4 16l4.5-4.5 3 3L16 10l4 4" /> Replace image
|
||||||
|
</button>
|
||||||
|
<button className="cover-btn" onClick={() => setCover({ kind: "default", image: "" })}>
|
||||||
|
<Icon d="M6 6l12 12M18 6L6 18" /> Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="cover-hint">Your artwork fills the cover as-is.</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="cover-palettes">
|
||||||
|
{COVER_PALETTES.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
className="cover-swatch"
|
||||||
|
data-on={cover.bg === p.bg && cover.ink === p.ink}
|
||||||
|
style={{ background: p.bg, color: p.ink }}
|
||||||
|
title={p.label}
|
||||||
|
onClick={() => setCover({ bg: p.bg, ink: p.ink })}
|
||||||
|
>
|
||||||
|
Aa
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<label className="cover-color" title="Background">
|
||||||
|
<input type="color" value={cover.bg} onChange={(e) => setCover({ bg: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label className="cover-color" title="Text">
|
||||||
|
<input type="color" value={cover.ink} onChange={(e) => setCover({ ink: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="cover-actions">
|
||||||
|
<button className="cover-btn" onClick={() => fileRef.current?.click()}>
|
||||||
|
<Icon d="M4 5h16v14H4zM4 16l4.5-4.5 3 3L16 10l4 4" /> Upload image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="cover-hint">Title, subtitle & author are set in Book setup.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) upload(file);
|
||||||
|
e.currentTarget.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+26
-4
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { generateHTML } from "@tiptap/core";
|
import { generateHTML } from "@tiptap/core";
|
||||||
import { useBook } from "../store/useBook";
|
import { COVER_ID, useBook } from "../store/useBook";
|
||||||
import type { TrimSize } from "../model/book";
|
import type { TrimSize } from "../model/book";
|
||||||
import { editorExtensions } from "../editor/extensions";
|
import { editorExtensions } from "../editor/extensions";
|
||||||
import { chapterToPdfInputs } from "../export/typst";
|
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
||||||
import { compilePdf, isDesktop } from "../ipc";
|
import { compilePdf, isDesktop } from "../ipc";
|
||||||
import { PdfPreview } from "./PdfPreview";
|
import { PdfPreview } from "./PdfPreview";
|
||||||
|
import { CoverArt } from "./CoverArt";
|
||||||
|
|
||||||
const TRIMS: { value: TrimSize; label: string }[] = [
|
const TRIMS: { value: TrimSize; label: string }[] = [
|
||||||
{ value: "6x9", label: "6 × 9 in" },
|
{ value: "6x9", label: "6 × 9 in" },
|
||||||
@@ -47,11 +48,12 @@ function PdfDock() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!book) return;
|
if (!book) return;
|
||||||
|
const coverActive = activeChapterId === COVER_ID;
|
||||||
const idx = Math.max(0, book.chapters.findIndex((c) => c.id === activeChapterId));
|
const idx = Math.max(0, book.chapters.findIndex((c) => c.id === activeChapterId));
|
||||||
clearTimeout(timer.current);
|
clearTimeout(timer.current);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
timer.current = setTimeout(() => {
|
timer.current = setTimeout(() => {
|
||||||
const { source, images } = chapterToPdfInputs(book, idx);
|
const { source, images } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
|
||||||
compilePdf(source, images)
|
compilePdf(source, images)
|
||||||
.then((bytes) => {
|
.then((bytes) => {
|
||||||
setPdf(bytes);
|
setPdf(bytes);
|
||||||
@@ -86,12 +88,32 @@ function PdfDock() {
|
|||||||
function HtmlDock() {
|
function HtmlDock() {
|
||||||
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 coverActive = activeChapterId === COVER_ID;
|
||||||
const chapters = book?.chapters ?? [];
|
const chapters = book?.chapters ?? [];
|
||||||
const idx = chapters.findIndex((c) => c.id === activeChapterId);
|
const idx = chapters.findIndex((c) => c.id === activeChapterId);
|
||||||
const chapter = chapters[idx] ?? chapters[0];
|
const chapter = chapters[idx] ?? chapters[0];
|
||||||
const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]);
|
const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]);
|
||||||
|
|
||||||
if (!book || !chapter) return null;
|
if (!book) return null;
|
||||||
|
|
||||||
|
if (coverActive) {
|
||||||
|
return (
|
||||||
|
<section className="dock">
|
||||||
|
<div className="dock-head">
|
||||||
|
<span className="label">Preview</span>
|
||||||
|
<div className="dock-tools">
|
||||||
|
<span className="meta">Cover</span>
|
||||||
|
<TrimSelect />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cover-stage">
|
||||||
|
<CoverArt book={book} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chapter) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dock">
|
<section className="dock">
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { Sidebar } from "./Sidebar";
|
|||||||
import { Dock } from "./Dock";
|
import { Dock } from "./Dock";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
|
import { CoverView } from "./CoverView";
|
||||||
import { Editor } from "../editor/Editor";
|
import { Editor } from "../editor/Editor";
|
||||||
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
||||||
import { useBook } from "../store/useBook";
|
import { COVER_ID, useBook } from "../store/useBook";
|
||||||
import { useTheme } from "../store/useTheme";
|
import { useTheme } from "../store/useTheme";
|
||||||
import type { Book } from "../model/book";
|
import type { Book } from "../model/book";
|
||||||
import { saveBook } from "../library";
|
import { saveBook } from "../library";
|
||||||
@@ -76,6 +77,7 @@ export function EditorView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!book) return null;
|
if (!book) return null;
|
||||||
|
const coverActive = activeChapterId === COVER_ID;
|
||||||
const idx = book.chapters.findIndex((c) => c.id === activeChapterId);
|
const idx = book.chapters.findIndex((c) => c.id === activeChapterId);
|
||||||
const chapter = book.chapters[idx] ?? book.chapters[0];
|
const chapter = book.chapters[idx] ?? book.chapters[0];
|
||||||
|
|
||||||
@@ -121,6 +123,10 @@ export function EditorView() {
|
|||||||
<div className="body">
|
<div className="body">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="editor-pane">
|
<main className="editor-pane">
|
||||||
|
{coverActive ? (
|
||||||
|
<CoverView />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<article className="sheet">
|
<article className="sheet">
|
||||||
<header className="chapter-opener">
|
<header className="chapter-opener">
|
||||||
<div className="chapter-num">Chapter {idx + 1}</div>
|
<div className="chapter-num">Chapter {idx + 1}</div>
|
||||||
@@ -140,6 +146,8 @@ export function EditorView() {
|
|||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
<FloatingToolbar editor={editor} />
|
<FloatingToolbar editor={editor} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
{dock && <Dock />}
|
{dock && <Dock />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useBook } from "../store/useBook";
|
import { useState } from "react";
|
||||||
|
import { COVER_ID, useBook } from "../store/useBook";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
@@ -6,14 +7,43 @@ export function Sidebar() {
|
|||||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||||
const setActiveChapter = useBook((s) => s.setActiveChapter);
|
const setActiveChapter = useBook((s) => s.setActiveChapter);
|
||||||
const addChapter = useBook((s) => s.addChapter);
|
const addChapter = useBook((s) => s.addChapter);
|
||||||
|
const moveChapter = useBook((s) => s.moveChapter);
|
||||||
const closeBook = useBook((s) => s.closeBook);
|
const closeBook = useBook((s) => s.closeBook);
|
||||||
|
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
|
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const onDragOver = (e: React.DragEvent, i: number) => {
|
||||||
|
if (dragIndex === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const after = e.clientY > rect.top + rect.height / 2;
|
||||||
|
setDropIndex(after ? i + 1 : i);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = () => {
|
||||||
|
if (dragIndex !== null && dropIndex !== null) {
|
||||||
|
const to = dragIndex < dropIndex ? dropIndex - 1 : dropIndex;
|
||||||
|
moveChapter(dragIndex, to);
|
||||||
|
}
|
||||||
|
setDragIndex(null);
|
||||||
|
setDropIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<button className="brand" onClick={closeBook} title="All books">
|
<button className="brand" onClick={closeBook} title="All books">
|
||||||
<Icon d="M14 7l-5 5 5 5" size={15} />
|
<Icon d="M14 7l-5 5 5 5" size={15} />
|
||||||
<span className="back-label">All books</span>
|
<span className="back-label">All books</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="cover-item"
|
||||||
|
data-active={activeChapterId === COVER_ID}
|
||||||
|
onClick={() => setActiveChapter(COVER_ID)}
|
||||||
|
>
|
||||||
|
<Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} />
|
||||||
|
<span className="title">Cover</span>
|
||||||
|
</button>
|
||||||
<div className="nav-label">Chapters</div>
|
<div className="nav-label">Chapters</div>
|
||||||
<ul className="chapters">
|
<ul className="chapters">
|
||||||
{chapters.map((chapter, i) => (
|
{chapters.map((chapter, i) => (
|
||||||
@@ -21,8 +51,22 @@ export function Sidebar() {
|
|||||||
key={chapter.id}
|
key={chapter.id}
|
||||||
className="chapter"
|
className="chapter"
|
||||||
data-active={chapter.id === activeChapterId}
|
data-active={chapter.id === activeChapterId}
|
||||||
|
data-dragging={dragIndex === i}
|
||||||
|
data-drop-before={dropIndex === i}
|
||||||
|
data-drop-after={i === chapters.length - 1 && dropIndex === chapters.length}
|
||||||
|
draggable
|
||||||
onClick={() => setActiveChapter(chapter.id)}
|
onClick={() => setActiveChapter(chapter.id)}
|
||||||
|
onDragStart={() => setDragIndex(i)}
|
||||||
|
onDragOver={(e) => onDragOver(e, i)}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setDragIndex(null);
|
||||||
|
setDropIndex(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<span className="grip" title="Drag to reorder">
|
||||||
|
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} />
|
||||||
|
</span>
|
||||||
<span className="num">{i + 1}</span>
|
<span className="num">{i + 1}</span>
|
||||||
<span className="title">{chapter.title || "Untitled"}</span>
|
<span className="title">{chapter.title || "Untitled"}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
import { Icon } from "../components/Icon";
|
import { Icon } from "../components/Icon";
|
||||||
|
|
||||||
@@ -11,8 +11,23 @@ function readImage(file: File): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(url: string): string {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (/^(https?:\/\/|mailto:|tel:|#|\/)/i.test(trimmed)) return trimmed;
|
||||||
|
return `https://${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const linkInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [linkOpen, setLinkOpen] = useState(false);
|
||||||
|
const [linkValue, setLinkValue] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (linkOpen) linkInputRef.current?.focus();
|
||||||
|
}, [linkOpen]);
|
||||||
|
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
const insertImage = async (file: File) => {
|
const insertImage = async (file: File) => {
|
||||||
@@ -20,6 +35,28 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
|||||||
editor.chain().focus().insertContent({ type: "figure", attrs: { src, placement: "full-width" } }).run();
|
editor.chain().focus().insertContent({ type: "figure", attrs: { src, placement: "full-width" } }).run();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openLink = () => {
|
||||||
|
setLinkValue((editor.getAttributes("link").href as string) ?? "");
|
||||||
|
setLinkOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyLink = () => {
|
||||||
|
const href = normalizeUrl(linkValue);
|
||||||
|
if (!href) {
|
||||||
|
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||||
|
} else if (editor.state.selection.empty && !editor.isActive("link")) {
|
||||||
|
editor.chain().focus().insertContent({ type: "text", text: href, marks: [{ type: "link", attrs: { href } }] }).run();
|
||||||
|
} else {
|
||||||
|
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
|
||||||
|
}
|
||||||
|
setLinkOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLink = () => {
|
||||||
|
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||||
|
setLinkOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const tool = (active: boolean, onClick: () => void, title: string, content: ReactNode) => (
|
const tool = (active: boolean, onClick: () => void, title: string, content: ReactNode) => (
|
||||||
<button
|
<button
|
||||||
className="tool"
|
className="tool"
|
||||||
@@ -41,6 +78,41 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
|||||||
{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("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" />)}
|
{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-sep" />
|
<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" />)}
|
||||||
|
{linkOpen && (
|
||||||
|
<>
|
||||||
|
<div className="link-pop-backdrop" onMouseDown={() => setLinkOpen(false)} />
|
||||||
|
<div className="link-pop" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
ref={linkInputRef}
|
||||||
|
className="link-input"
|
||||||
|
value={linkValue}
|
||||||
|
placeholder="https://…"
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(e) => setLinkValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
applyLink();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setLinkOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="link-btn" onMouseDown={(e) => e.preventDefault()} onClick={applyLink}>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
{editor.isActive("link") && (
|
||||||
|
<button className="link-btn ghost" onMouseDown={(e) => e.preventDefault()} onClick={removeLink} title="Remove link">
|
||||||
|
<Icon d="M18 6L6 18M6 6l12 12" size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
{tool(false, () => editor.chain().focus().setHorizontalRule().run(), "Scene break", <Icon d="M5 12h5M14 12h5" />)}
|
{tool(false, () => editor.chain().focus().setHorizontalRule().run(), "Scene break", <Icon d="M5 12h5M14 12h5" />)}
|
||||||
{tool(false, () => fileRef.current?.click(), "Insert image", <Icon d="M4 5h16v14H4zM4 16l4.5-4.5 3 3L16 10l4 4" />)}
|
{tool(false, () => fileRef.current?.click(), "Insert image", <Icon d="M4 5h16v14H4zM4 16l4.5-4.5 3 3L16 10l4 4" />)}
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const editorExtensions: Extensions = [
|
|||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: { levels: [2, 3] },
|
heading: { levels: [2, 3] },
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
|
link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: ({ node }) => (node.type.name === "heading" ? "" : "Begin your chapter…"),
|
placeholder: ({ node }) => (node.type.name === "heading" ? "" : "Begin your chapter…"),
|
||||||
|
|||||||
+118
-7
@@ -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 } from "../model/book";
|
import { type Book, TRIM_DIMS } from "../model/book";
|
||||||
|
|
||||||
export interface EpubFile {
|
export interface EpubFile {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -41,9 +41,11 @@ function mediaType(path: string): string {
|
|||||||
function inline(node: JSONContent): string {
|
function inline(node: JSONContent): string {
|
||||||
if (node.type === "text") {
|
if (node.type === "text") {
|
||||||
let text = esc(node.text ?? "");
|
let text = esc(node.text ?? "");
|
||||||
const marks = (node.marks ?? []).map((m) => m.type);
|
const marks = node.marks ?? [];
|
||||||
if (marks.includes("bold")) text = `<strong>${text}</strong>`;
|
if (marks.some((m) => m.type === "bold")) text = `<strong>${text}</strong>`;
|
||||||
if (marks.includes("italic")) text = `<em>${text}</em>`;
|
if (marks.some((m) => m.type === "italic")) text = `<em>${text}</em>`;
|
||||||
|
const href = marks.find((m) => m.type === "link")?.attrs?.href;
|
||||||
|
if (href) text = `<a href="${attr(href)}">${text}</a>`;
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
if (node.type === "hardBreak") return "<br/>";
|
if (node.type === "hardBreak") return "<br/>";
|
||||||
@@ -103,6 +105,88 @@ function chapterBody(content: JSONContent, paths: Map<string, string>): string {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wrapTitle(title: string, max = 16): string[] {
|
||||||
|
const words = title.split(/\s+/).filter(Boolean);
|
||||||
|
const lines: string[] = [];
|
||||||
|
let line = "";
|
||||||
|
for (const word of words) {
|
||||||
|
if (line && (line + " " + word).length > max) {
|
||||||
|
lines.push(line);
|
||||||
|
line = word;
|
||||||
|
} else {
|
||||||
|
line = line ? line + " " + word : word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line) lines.push(line);
|
||||||
|
return lines.length ? lines.slice(0, 4) : ["Untitled"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverSvg(book: Book): string {
|
||||||
|
const c = book.cover;
|
||||||
|
const m = book.metadata;
|
||||||
|
const dims = TRIM_DIMS[book.settings.trim];
|
||||||
|
const W = 1200;
|
||||||
|
const H = Math.round((W * dims.h) / dims.w);
|
||||||
|
|
||||||
|
const lines = wrapTitle(m.title || "Untitled");
|
||||||
|
const titleSize = lines.length > 2 ? 88 : 104;
|
||||||
|
const lineH = titleSize * 1.12;
|
||||||
|
const blockMid = H * 0.4;
|
||||||
|
const firstY = Math.round(blockMid - ((lines.length - 1) * lineH) / 2);
|
||||||
|
const titleSpans = lines
|
||||||
|
.map((l, i) => `<tspan x="${W / 2}" y="${firstY + Math.round(i * lineH)}">${esc(l)}</tspan>`)
|
||||||
|
.join("");
|
||||||
|
const ruleY = firstY + Math.round((lines.length - 1) * lineH + 84);
|
||||||
|
const rule = `<line x1="${W / 2 - 96}" y1="${ruleY}" x2="${W / 2 + 96}" y2="${ruleY}" stroke="${c.ink}" stroke-width="3" opacity="0.7"/>`;
|
||||||
|
const subtitle = m.subtitle
|
||||||
|
? `<text x="${W / 2}" y="${ruleY + 96}" font-family="Georgia, 'Times New Roman', serif" font-style="italic" font-size="50" fill="${c.ink}" text-anchor="middle" opacity="0.85">${esc(m.subtitle)}</text>`
|
||||||
|
: "";
|
||||||
|
const author = m.author
|
||||||
|
? `<text x="${W / 2}" y="${H - 132}" font-family="'Helvetica Neue', Arial, sans-serif" font-size="40" font-weight="600" letter-spacing="9" fill="${c.ink}" text-anchor="middle" opacity="0.92">${esc(m.author.toUpperCase())}</text>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
||||||
|
<rect width="${W}" height="${H}" fill="${c.bg}"/>
|
||||||
|
<text font-family="Georgia, 'Times New Roman', serif" font-size="${titleSize}" font-weight="500" fill="${c.ink}" text-anchor="middle">${titleSpans}</text>
|
||||||
|
${rule}
|
||||||
|
${subtitle}
|
||||||
|
${author}
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCover(book: Book): { asset: EpubFile; href: string; mediaType: string } {
|
||||||
|
const c = book.cover;
|
||||||
|
if (c.kind === "image" && c.image.startsWith("data:")) {
|
||||||
|
const href = `assets/cover.${imageExtension(c.image)}`;
|
||||||
|
return {
|
||||||
|
asset: { path: `OEBPS/${href}`, data: c.image.slice(c.image.indexOf(",") + 1), encoding: "base64" },
|
||||||
|
href,
|
||||||
|
mediaType: mediaType(href),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const href = "assets/cover.svg";
|
||||||
|
return {
|
||||||
|
asset: { path: `OEBPS/${href}`, data: coverSvg(book), encoding: "utf8" },
|
||||||
|
href,
|
||||||
|
mediaType: "image/svg+xml",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverXhtml(book: Book, href: string): string {
|
||||||
|
const title = book.metadata.title || "Cover";
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
|
||||||
|
<head>
|
||||||
|
<title>${esc(title)}</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="style.css"/>
|
||||||
|
</head>
|
||||||
|
<body epub:type="cover">
|
||||||
|
<section class="cover"><img src="${attr(href)}" alt="${attr(title)}"/></section>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
function extractFigures(book: Book): { assets: EpubFile[]; paths: Map<string, string> } {
|
function extractFigures(book: Book): { assets: EpubFile[]; paths: Map<string, string> } {
|
||||||
const paths = new Map<string, string>();
|
const paths = new Map<string, string>();
|
||||||
const assets: EpubFile[] = [];
|
const assets: EpubFile[] = [];
|
||||||
@@ -142,9 +226,16 @@ function containerXml(): string {
|
|||||||
</container>`;
|
</container>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function contentOpf(book: Book, chapterIds: string[], assetPaths: string[]): string {
|
function contentOpf(
|
||||||
|
book: Book,
|
||||||
|
chapterIds: string[],
|
||||||
|
assetPaths: string[],
|
||||||
|
cover: { href: string; mediaType: string },
|
||||||
|
): string {
|
||||||
const meta = book.metadata;
|
const meta = book.metadata;
|
||||||
const manifest: string[] = [
|
const manifest: string[] = [
|
||||||
|
`<item id="cover-image" href="${attr(cover.href)}" media-type="${cover.mediaType}" properties="cover-image"/>`,
|
||||||
|
`<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>`,
|
||||||
`<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
|
`<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
|
||||||
`<item id="css" href="style.css" media-type="text/css"/>`,
|
`<item id="css" href="style.css" media-type="text/css"/>`,
|
||||||
`<item id="font-regular" href="fonts/Literata-VF.ttf" media-type="font/ttf"/>`,
|
`<item id="font-regular" href="fonts/Literata-VF.ttf" media-type="font/ttf"/>`,
|
||||||
@@ -161,7 +252,7 @@ function contentOpf(book: Book, chapterIds: string[], assetPaths: string[]): str
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const spine = [`<itemref idref="nav" linear="no"/>`]
|
const spine = [`<itemref idref="cover" linear="yes"/>`, `<itemref idref="nav" linear="no"/>`]
|
||||||
.concat(chapterIds.map((_, i) => `<itemref idref="chapter-${i + 1}"/>`))
|
.concat(chapterIds.map((_, i) => `<itemref idref="chapter-${i + 1}"/>`))
|
||||||
.join("\n ");
|
.join("\n ");
|
||||||
|
|
||||||
@@ -172,6 +263,7 @@ function contentOpf(book: Book, chapterIds: string[], assetPaths: string[]): str
|
|||||||
<dc:title>${esc(meta.title || "Untitled")}</dc:title>
|
<dc:title>${esc(meta.title || "Untitled")}</dc:title>
|
||||||
<dc:creator>${esc(meta.author || "")}</dc:creator>
|
<dc:creator>${esc(meta.author || "")}</dc:creator>
|
||||||
<dc:language>${esc(meta.language || "en")}</dc:language>
|
<dc:language>${esc(meta.language || "en")}</dc:language>
|
||||||
|
<meta name="cover" content="cover-image"/>
|
||||||
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
|
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
|
||||||
</metadata>
|
</metadata>
|
||||||
<manifest>
|
<manifest>
|
||||||
@@ -245,6 +337,18 @@ html {
|
|||||||
font-family: "Literata", Georgia, serif;
|
font-family: "Literata", Georgia, serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
text-align: center;
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0 5%;
|
margin: 0 5%;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -366,15 +470,22 @@ figure.placement-float-top {
|
|||||||
|
|
||||||
export function bookToEpub(book: Book): EpubFile[] {
|
export function bookToEpub(book: Book): EpubFile[] {
|
||||||
const { assets, paths } = extractFigures(book);
|
const { assets, paths } = extractFigures(book);
|
||||||
|
const cover = buildCover(book);
|
||||||
const chapterIds = book.chapters.map((c) => c.id);
|
const chapterIds = book.chapters.map((c) => c.id);
|
||||||
const assetPaths = Array.from(paths.values());
|
const assetPaths = Array.from(paths.values());
|
||||||
|
|
||||||
const files: EpubFile[] = [
|
const files: EpubFile[] = [
|
||||||
{ path: "mimetype", data: "application/epub+zip", encoding: "utf8" },
|
{ path: "mimetype", data: "application/epub+zip", encoding: "utf8" },
|
||||||
{ path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" },
|
{ path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" },
|
||||||
{ path: "OEBPS/content.opf", data: contentOpf(book, chapterIds, assetPaths), encoding: "utf8" },
|
{
|
||||||
|
path: "OEBPS/content.opf",
|
||||||
|
data: contentOpf(book, chapterIds, assetPaths, cover),
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
{ path: "OEBPS/cover.xhtml", data: coverXhtml(book, cover.href), encoding: "utf8" },
|
||||||
{ path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" },
|
{ path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" },
|
||||||
{ path: "OEBPS/style.css", data: styleCss(), encoding: "utf8" },
|
{ path: "OEBPS/style.css", data: styleCss(), encoding: "utf8" },
|
||||||
|
cover.asset,
|
||||||
];
|
];
|
||||||
|
|
||||||
book.chapters.forEach((_, i) => {
|
book.chapters.forEach((_, i) => {
|
||||||
|
|||||||
+51
-6
@@ -28,9 +28,11 @@ function str(value: string): string {
|
|||||||
function inline(node: JSONContent): string {
|
function inline(node: JSONContent): string {
|
||||||
if (node.type === "text") {
|
if (node.type === "text") {
|
||||||
let text = esc(node.text ?? "");
|
let text = esc(node.text ?? "");
|
||||||
const marks = (node.marks ?? []).map((m) => m.type);
|
const marks = node.marks ?? [];
|
||||||
if (marks.includes("italic")) text = `#emph[${text}]`;
|
if (marks.some((m) => m.type === "italic")) text = `#emph[${text}]`;
|
||||||
if (marks.includes("bold")) text = `#strong[${text}]`;
|
if (marks.some((m) => m.type === "bold")) text = `#strong[${text}]`;
|
||||||
|
const href = marks.find((m) => m.type === "link")?.attrs?.href;
|
||||||
|
if (href) text = `#link(${str(href)})[${text}]`;
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
if (node.type === "hardBreak") return "#linebreak()";
|
if (node.type === "hardBreak") return "#linebreak()";
|
||||||
@@ -143,6 +145,41 @@ function imageExtension(dataUrl: string): string {
|
|||||||
return kind === "jpeg" ? "jpg" : kind;
|
return kind === "jpeg" ? "jpg" : kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function coverBlock(book: Book, coverPath?: string): string {
|
||||||
|
const c = book.cover;
|
||||||
|
const meta = book.metadata;
|
||||||
|
|
||||||
|
if (c.kind === "image" && coverPath) {
|
||||||
|
return `#page(margin: 0pt, numbering: none)[#image(${str(coverPath)}, width: 100%, height: 100%, fit: "cover")]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitle = meta.subtitle
|
||||||
|
? `#v(0.55em)\n #text(font: "Literata", size: 14pt, style: "italic")[${esc(meta.subtitle)}]`
|
||||||
|
: "";
|
||||||
|
const author = meta.author
|
||||||
|
? `#v(1fr)\n #text(font: "Hanken Grotesk", size: 11pt, weight: "semibold", tracking: 0.24em)[#upper[${esc(meta.author)}]]\n #v(0.55in)`
|
||||||
|
: "#v(1fr)";
|
||||||
|
|
||||||
|
return `#page(margin: 0pt, numbering: none, fill: rgb(${str(c.bg)}))[
|
||||||
|
#set align(center)
|
||||||
|
#set text(fill: rgb(${str(c.ink)}))
|
||||||
|
#v(1fr)
|
||||||
|
#text(font: "Literata", size: 30pt, weight: "medium")[${esc(meta.title || "Untitled")}]
|
||||||
|
#v(0.5em)
|
||||||
|
#line(length: 16%, stroke: 0.6pt + rgb(${str(c.ink)}))
|
||||||
|
${subtitle}
|
||||||
|
${author}
|
||||||
|
]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushCoverImage(book: Book, images: ImageInput[]): string | undefined {
|
||||||
|
const c = book.cover;
|
||||||
|
if (c.kind !== "image" || !c.image.startsWith("data:")) return undefined;
|
||||||
|
const path = `assets/cover.${imageExtension(c.image)}`;
|
||||||
|
images.push({ path, data: c.image.slice(c.image.indexOf(",") + 1) });
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
function collectImages(contents: JSONContent[]): { images: ImageInput[]; paths: Map<string, string> } {
|
function collectImages(contents: JSONContent[]): { images: ImageInput[]; paths: Map<string, string> } {
|
||||||
const paths = new Map<string, string>();
|
const paths = new Map<string, string>();
|
||||||
const images: ImageInput[] = [];
|
const images: ImageInput[] = [];
|
||||||
@@ -167,8 +204,9 @@ 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bookToTypst(book: Book, paths: Map<string, string> = new Map()): 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 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)
|
||||||
@@ -183,12 +221,19 @@ export function bookToTypst(book: Book, paths: Map<string, string> = new Map()):
|
|||||||
})
|
})
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
return `${preamble(book)}\n\n${front}\n\n${body}\n`;
|
return `${preamble(book)}\n\n${cover}\n\n${front}\n\n${body}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
|
export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
|
||||||
const { images, paths } = extractImages(book);
|
const { images, paths } = extractImages(book);
|
||||||
return { source: bookToTypst(book, paths), images };
|
const coverPath = pushCoverImage(book, images);
|
||||||
|
return { source: bookToTypst(book, paths, coverPath), images };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coverToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
|
||||||
|
const images: ImageInput[] = [];
|
||||||
|
const coverPath = pushCoverImage(book, images);
|
||||||
|
return { source: `${preamble(book)}\n\n${coverBlock(book, coverPath)}\n`, images };
|
||||||
}
|
}
|
||||||
|
|
||||||
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
|
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import type { Book, BookMetadata, Chapter } from "../model/book";
|
import { type Book, type BookMetadata, type Chapter, createCover } from "../model/book";
|
||||||
|
|
||||||
export interface RawFile {
|
export interface RawFile {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -404,6 +404,7 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
|
|||||||
metadata,
|
metadata,
|
||||||
theme: "quiet-press",
|
theme: "quiet-press",
|
||||||
settings: { trim: "6x9", bleed: true },
|
settings: { trim: "6x9", bleed: true },
|
||||||
|
cover: createCover(),
|
||||||
chapters,
|
chapters,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,42 @@ export interface BookSettings {
|
|||||||
bleed: boolean;
|
bleed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CoverKind = "default" | "image";
|
||||||
|
|
||||||
|
export interface Cover {
|
||||||
|
kind: CoverKind;
|
||||||
|
image: string;
|
||||||
|
bg: string;
|
||||||
|
ink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoverPalette {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
bg: string;
|
||||||
|
ink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COVER_PALETTES: CoverPalette[] = [
|
||||||
|
{ id: "paper", label: "Paper", bg: "#f4efe3", ink: "#23201b" },
|
||||||
|
{ id: "ink", label: "Ink", bg: "#23201b", ink: "#f3ecdd" },
|
||||||
|
{ id: "forest", label: "Forest", bg: "#2c382f", ink: "#eae3d2" },
|
||||||
|
{ id: "oxblood", label: "Oxblood", bg: "#4a2327", ink: "#f0e4d8" },
|
||||||
|
{ id: "navy", label: "Navy", bg: "#222e3e", ink: "#e9e3d4" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TRIM_DIMS: Record<TrimSize, { w: number; h: number }> = {
|
||||||
|
"6x9": { w: 6, h: 9 },
|
||||||
|
"5.5x8.5": { w: 5.5, h: 8.5 },
|
||||||
|
"5x8": { w: 5, h: 8 },
|
||||||
|
a5: { w: 148, h: 210 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function trimRatio(trim: TrimSize): number {
|
||||||
|
const { w, h } = TRIM_DIMS[trim];
|
||||||
|
return w / h;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Chapter {
|
export interface Chapter {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -28,6 +64,7 @@ export interface Book {
|
|||||||
metadata: BookMetadata;
|
metadata: BookMetadata;
|
||||||
theme: string;
|
theme: string;
|
||||||
settings: BookSettings;
|
settings: BookSettings;
|
||||||
|
cover: Cover;
|
||||||
chapters: Chapter[];
|
chapters: Chapter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +80,14 @@ export function createChapter(title = "Untitled chapter"): Chapter {
|
|||||||
return { id: crypto.randomUUID(), title, content: emptyDoc() };
|
return { id: crypto.randomUUID(), title, content: emptyDoc() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCover(): Cover {
|
||||||
|
return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBook(book: Book): Book {
|
||||||
|
return { ...book, cover: book.cover ? { ...createCover(), ...book.cover } : createCover() };
|
||||||
|
}
|
||||||
|
|
||||||
export function createBook(): Book {
|
export function createBook(): Book {
|
||||||
return {
|
return {
|
||||||
schema: "margin/1",
|
schema: "margin/1",
|
||||||
@@ -50,6 +95,7 @@ export function createBook(): Book {
|
|||||||
metadata: { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" },
|
metadata: { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" },
|
||||||
theme: "quiet-press",
|
theme: "quiet-press",
|
||||||
settings: { trim: "6x9", bleed: true },
|
settings: { trim: "6x9", bleed: true },
|
||||||
|
cover: createCover(),
|
||||||
chapters: [createChapter("Chapter One")],
|
chapters: [createChapter("Chapter One")],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-2
@@ -1,6 +1,15 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import { type Book, type BookMetadata, type BookSettings, createChapter } from "../model/book";
|
import {
|
||||||
|
type Book,
|
||||||
|
type BookMetadata,
|
||||||
|
type BookSettings,
|
||||||
|
type Cover,
|
||||||
|
createChapter,
|
||||||
|
normalizeBook,
|
||||||
|
} from "../model/book";
|
||||||
|
|
||||||
|
export const COVER_ID = "__cover__";
|
||||||
|
|
||||||
interface BookState {
|
interface BookState {
|
||||||
book: Book | null;
|
book: Book | null;
|
||||||
@@ -12,8 +21,10 @@ interface BookState {
|
|||||||
setChapterContent: (id: string, content: JSONContent) => void;
|
setChapterContent: (id: string, content: JSONContent) => void;
|
||||||
setChapterTitle: (id: string, title: string) => void;
|
setChapterTitle: (id: string, title: string) => void;
|
||||||
addChapter: () => void;
|
addChapter: () => void;
|
||||||
|
moveChapter: (from: number, to: number) => 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;
|
||||||
markSaved: () => void;
|
markSaved: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +32,10 @@ export const useBook = create<BookState>((set) => ({
|
|||||||
book: null,
|
book: null,
|
||||||
activeChapterId: "",
|
activeChapterId: "",
|
||||||
dirty: false,
|
dirty: false,
|
||||||
openBook: (book) => set({ book, activeChapterId: book.chapters[0]?.id ?? "", dirty: false }),
|
openBook: (book) => {
|
||||||
|
const normalized = normalizeBook(book);
|
||||||
|
set({ book: normalized, activeChapterId: normalized.chapters[0]?.id ?? "", dirty: false });
|
||||||
|
},
|
||||||
closeBook: () => set({ book: null, activeChapterId: "", dirty: false }),
|
closeBook: () => set({ book: null, activeChapterId: "", dirty: false }),
|
||||||
setActiveChapter: (id) => set({ activeChapterId: id }),
|
setActiveChapter: (id) => set({ activeChapterId: id }),
|
||||||
setChapterContent: (id, content) =>
|
setChapterContent: (id, content) =>
|
||||||
@@ -42,9 +56,20 @@ export const useBook = create<BookState>((set) => ({
|
|||||||
const chapter = createChapter();
|
const chapter = createChapter();
|
||||||
return { activeChapterId: chapter.id, dirty: true, book: { ...s.book, chapters: [...s.book.chapters, chapter] } };
|
return { activeChapterId: chapter.id, dirty: true, book: { ...s.book, chapters: [...s.book.chapters, chapter] } };
|
||||||
}),
|
}),
|
||||||
|
moveChapter: (from, to) =>
|
||||||
|
set((s) => {
|
||||||
|
if (!s.book || from === to) return {};
|
||||||
|
const chapters = [...s.book.chapters];
|
||||||
|
if (from < 0 || from >= chapters.length || to < 0 || to >= chapters.length) return {};
|
||||||
|
const [moved] = chapters.splice(from, 1);
|
||||||
|
chapters.splice(to, 0, moved);
|
||||||
|
return { dirty: true, book: { ...s.book, chapters } };
|
||||||
|
}),
|
||||||
setMetadata: (patch) =>
|
setMetadata: (patch) =>
|
||||||
set((s) => (s.book ? { dirty: true, book: { ...s.book, metadata: { ...s.book.metadata, ...patch } } } : {})),
|
set((s) => (s.book ? { dirty: true, book: { ...s.book, metadata: { ...s.book.metadata, ...patch } } } : {})),
|
||||||
setSettings: (patch) =>
|
setSettings: (patch) =>
|
||||||
set((s) => (s.book ? { dirty: true, book: { ...s.book, settings: { ...s.book.settings, ...patch } } } : {})),
|
set((s) => (s.book ? { dirty: true, book: { ...s.book, settings: { ...s.book.settings, ...patch } } } : {})),
|
||||||
|
setCover: (patch) =>
|
||||||
|
set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})),
|
||||||
markSaved: () => set({ dirty: false }),
|
markSaved: () => set({ dirty: false }),
|
||||||
}));
|
}));
|
||||||
@@ -193,6 +193,49 @@ button {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chapter .grip {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-self: center;
|
||||||
|
margin: 0 -5px 0 -6px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
opacity: 0;
|
||||||
|
cursor: grab;
|
||||||
|
transition: opacity 120ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter:hover .grip,
|
||||||
|
.chapter[data-active="true"] .grip {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter .grip:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter[data-dragging="true"] {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter[data-drop-before="true"]::after,
|
||||||
|
.chapter[data-drop-after="true"]::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter[data-drop-before="true"]::after {
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter[data-drop-after="true"]::after {
|
||||||
|
bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
.add-chapter {
|
.add-chapter {
|
||||||
margin: 8px 18px 4px;
|
margin: 8px 18px 4px;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
@@ -277,6 +320,14 @@ button {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--line-strong);
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.prose blockquote {
|
.prose blockquote {
|
||||||
margin: 1.7rem 0;
|
margin: 1.7rem 0;
|
||||||
padding-left: 1.2rem;
|
padding-left: 1.2rem;
|
||||||
@@ -528,6 +579,80 @@ button {
|
|||||||
margin: 0 5px;
|
margin: 0 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-pop-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-pop {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 10px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 7;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
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;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-input::placeholder {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-2);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: opacity 120ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn.ghost {
|
||||||
|
padding: 0 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn.ghost:hover {
|
||||||
|
background: var(--accent-wash);
|
||||||
|
color: var(--ink);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.figure {
|
.figure {
|
||||||
margin: 1.8rem 0;
|
margin: 1.8rem 0;
|
||||||
}
|
}
|
||||||
@@ -940,3 +1065,238 @@ button {
|
|||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background: var(--accent-ink);
|
background: var(--accent-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cover */
|
||||||
|
.cover-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
margin: 2px 10px 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-item .title {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-item svg {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-item:hover {
|
||||||
|
background: var(--accent-wash);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-item[data-active="true"] {
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: 0 1px 2px rgba(35, 32, 27, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-item[data-active="true"] svg {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-item[data-active="true"]::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -10px;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-pane {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--measure);
|
||||||
|
padding: 64px 24px 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-stage {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-art {
|
||||||
|
width: min(360px, 80%);
|
||||||
|
container-type: size;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: var(--shadow-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-face {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-face-image {
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-face-default {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12cqw 10cqw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-title {
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 9.5cqw;
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-rule {
|
||||||
|
display: block;
|
||||||
|
width: 15cqw;
|
||||||
|
height: 1px;
|
||||||
|
margin: 5.5cqw 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-subtitle {
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 4.6cqw;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.84;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-author {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 9cqw;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 8cqw;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 3.4cqw;
|
||||||
|
letter-spacing: 0.24em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-palettes {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-swatch {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms var(--ease), box-shadow 120ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-swatch:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-swatch[data-on="true"] {
|
||||||
|
box-shadow: 0 0 0 2px var(--paper), 0 0 0 4px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-color {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-color input {
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px;
|
||||||
|
width: calc(100% + 8px);
|
||||||
|
height: calc(100% + 8px);
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: var(--raised);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 120ms var(--ease), color 120ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-2);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
--pane-sidebar: 248px;
|
--pane-sidebar: 248px;
|
||||||
--pane-dock: 384px;
|
--pane-dock: 384px;
|
||||||
--measure: 34em;
|
--measure: 46em;
|
||||||
|
|
||||||
--t-1: 11px;
|
--t-1: 11px;
|
||||||
--t-2: 12px;
|
--t-2: 12px;
|
||||||
|
|||||||
Reference in new issue
Block a user