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 { generateHTML } from "@tiptap/core";
|
||||
import { useBook } from "../store/useBook";
|
||||
import { COVER_ID, useBook } from "../store/useBook";
|
||||
import type { TrimSize } from "../model/book";
|
||||
import { editorExtensions } from "../editor/extensions";
|
||||
import { chapterToPdfInputs } from "../export/typst";
|
||||
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
||||
import { compilePdf, isDesktop } from "../ipc";
|
||||
import { PdfPreview } from "./PdfPreview";
|
||||
import { CoverArt } from "./CoverArt";
|
||||
|
||||
const TRIMS: { value: TrimSize; label: string }[] = [
|
||||
{ value: "6x9", label: "6 × 9 in" },
|
||||
@@ -47,11 +48,12 @@ function PdfDock() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!book) return;
|
||||
const coverActive = activeChapterId === COVER_ID;
|
||||
const idx = Math.max(0, book.chapters.findIndex((c) => c.id === activeChapterId));
|
||||
clearTimeout(timer.current);
|
||||
setBusy(true);
|
||||
timer.current = setTimeout(() => {
|
||||
const { source, images } = chapterToPdfInputs(book, idx);
|
||||
const { source, images } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
|
||||
compilePdf(source, images)
|
||||
.then((bytes) => {
|
||||
setPdf(bytes);
|
||||
@@ -86,12 +88,32 @@ function PdfDock() {
|
||||
function HtmlDock() {
|
||||
const book = useBook((s) => s.book);
|
||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||
const coverActive = activeChapterId === COVER_ID;
|
||||
const chapters = book?.chapters ?? [];
|
||||
const idx = chapters.findIndex((c) => c.id === activeChapterId);
|
||||
const chapter = chapters[idx] ?? chapters[0];
|
||||
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 (
|
||||
<section className="dock">
|
||||
|
||||
@@ -4,9 +4,10 @@ import { Sidebar } from "./Sidebar";
|
||||
import { Dock } from "./Dock";
|
||||
import { Icon } from "./Icon";
|
||||
import { Settings } from "./Settings";
|
||||
import { CoverView } from "./CoverView";
|
||||
import { Editor } from "../editor/Editor";
|
||||
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
||||
import { useBook } from "../store/useBook";
|
||||
import { COVER_ID, useBook } from "../store/useBook";
|
||||
import { useTheme } from "../store/useTheme";
|
||||
import type { Book } from "../model/book";
|
||||
import { saveBook } from "../library";
|
||||
@@ -76,6 +77,7 @@ export function EditorView() {
|
||||
};
|
||||
|
||||
if (!book) return null;
|
||||
const coverActive = activeChapterId === COVER_ID;
|
||||
const idx = book.chapters.findIndex((c) => c.id === activeChapterId);
|
||||
const chapter = book.chapters[idx] ?? book.chapters[0];
|
||||
|
||||
@@ -121,6 +123,10 @@ export function EditorView() {
|
||||
<div className="body">
|
||||
<Sidebar />
|
||||
<main className="editor-pane">
|
||||
{coverActive ? (
|
||||
<CoverView />
|
||||
) : (
|
||||
<>
|
||||
<article className="sheet">
|
||||
<header className="chapter-opener">
|
||||
<div className="chapter-num">Chapter {idx + 1}</div>
|
||||
@@ -140,6 +146,8 @@ export function EditorView() {
|
||||
/>
|
||||
</article>
|
||||
<FloatingToolbar editor={editor} />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
{dock && <Dock />}
|
||||
</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";
|
||||
|
||||
export function Sidebar() {
|
||||
@@ -6,14 +7,43 @@ export function Sidebar() {
|
||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||
const setActiveChapter = useBook((s) => s.setActiveChapter);
|
||||
const addChapter = useBook((s) => s.addChapter);
|
||||
const moveChapter = useBook((s) => s.moveChapter);
|
||||
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 (
|
||||
<aside className="sidebar">
|
||||
<button className="brand" onClick={closeBook} title="All books">
|
||||
<Icon d="M14 7l-5 5 5 5" size={15} />
|
||||
<span className="back-label">All books</span>
|
||||
</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>
|
||||
<ul className="chapters">
|
||||
{chapters.map((chapter, i) => (
|
||||
@@ -21,8 +51,22 @@ export function Sidebar() {
|
||||
key={chapter.id}
|
||||
className="chapter"
|
||||
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)}
|
||||
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="title">{chapter.title || "Untitled"}</span>
|
||||
</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 { 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 }) {
|
||||
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;
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
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) => (
|
||||
<button
|
||||
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("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-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, () => fileRef.current?.click(), "Insert image", <Icon d="M4 5h16v14H4zM4 16l4.5-4.5 3 3L16 10l4 4" />)}
|
||||
<input
|
||||
|
||||
@@ -7,6 +7,7 @@ export const editorExtensions: Extensions = [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3] },
|
||||
codeBlock: false,
|
||||
link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => (node.type.name === "heading" ? "" : "Begin your chapter…"),
|
||||
|
||||
+118
-7
@@ -1,6 +1,6 @@
|
||||
import type { JSONContent } from "@tiptap/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 {
|
||||
path: string;
|
||||
@@ -41,9 +41,11 @@ function mediaType(path: string): string {
|
||||
function inline(node: JSONContent): string {
|
||||
if (node.type === "text") {
|
||||
let text = esc(node.text ?? "");
|
||||
const marks = (node.marks ?? []).map((m) => m.type);
|
||||
if (marks.includes("bold")) text = `<strong>${text}</strong>`;
|
||||
if (marks.includes("italic")) text = `<em>${text}</em>`;
|
||||
const marks = node.marks ?? [];
|
||||
if (marks.some((m) => m.type === "bold")) text = `<strong>${text}</strong>`;
|
||||
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;
|
||||
}
|
||||
if (node.type === "hardBreak") return "<br/>";
|
||||
@@ -103,6 +105,88 @@ function chapterBody(content: JSONContent, paths: Map<string, string>): string {
|
||||
.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> } {
|
||||
const paths = new Map<string, string>();
|
||||
const assets: EpubFile[] = [];
|
||||
@@ -142,9 +226,16 @@ function containerXml(): string {
|
||||
</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 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="css" href="style.css" media-type="text/css"/>`,
|
||||
`<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}"/>`))
|
||||
.join("\n ");
|
||||
|
||||
@@ -172,6 +263,7 @@ function contentOpf(book: Book, chapterIds: string[], assetPaths: string[]): str
|
||||
<dc:title>${esc(meta.title || "Untitled")}</dc:title>
|
||||
<dc:creator>${esc(meta.author || "")}</dc:creator>
|
||||
<dc:language>${esc(meta.language || "en")}</dc:language>
|
||||
<meta name="cover" content="cover-image"/>
|
||||
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
@@ -245,6 +337,18 @@ html {
|
||||
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 {
|
||||
margin: 0 5%;
|
||||
line-height: 1.5;
|
||||
@@ -366,15 +470,22 @@ figure.placement-float-top {
|
||||
|
||||
export function bookToEpub(book: Book): EpubFile[] {
|
||||
const { assets, paths } = extractFigures(book);
|
||||
const cover = buildCover(book);
|
||||
const chapterIds = book.chapters.map((c) => c.id);
|
||||
const assetPaths = Array.from(paths.values());
|
||||
|
||||
const files: EpubFile[] = [
|
||||
{ path: "mimetype", data: "application/epub+zip", 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/style.css", data: styleCss(), encoding: "utf8" },
|
||||
cover.asset,
|
||||
];
|
||||
|
||||
book.chapters.forEach((_, i) => {
|
||||
|
||||
+51
-6
@@ -28,9 +28,11 @@ function str(value: string): string {
|
||||
function inline(node: JSONContent): string {
|
||||
if (node.type === "text") {
|
||||
let text = esc(node.text ?? "");
|
||||
const marks = (node.marks ?? []).map((m) => m.type);
|
||||
if (marks.includes("italic")) text = `#emph[${text}]`;
|
||||
if (marks.includes("bold")) text = `#strong[${text}]`;
|
||||
const marks = node.marks ?? [];
|
||||
if (marks.some((m) => m.type === "italic")) text = `#emph[${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;
|
||||
}
|
||||
if (node.type === "hardBreak") return "#linebreak()";
|
||||
@@ -143,6 +145,41 @@ function imageExtension(dataUrl: string): string {
|
||||
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> } {
|
||||
const paths = new Map<string, string>();
|
||||
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));
|
||||
}
|
||||
|
||||
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 cover = `${coverBlock(book, coverPath)}\n#pagebreak(weak: true)`;
|
||||
const front = `#set page(numbering: none)
|
||||
#titlepage(${str(meta.title || "Untitled")}, ${str(meta.subtitle)}, ${str(meta.author)})
|
||||
#pagebreak(weak: true)
|
||||
@@ -183,12 +221,19 @@ export function bookToTypst(book: Book, paths: Map<string, string> = new Map()):
|
||||
})
|
||||
.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[] } {
|
||||
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 {
|
||||
|
||||
+2
-1
@@ -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, BookMetadata, Chapter } from "../model/book";
|
||||
import { type Book, type BookMetadata, type Chapter, createCover } from "../model/book";
|
||||
|
||||
export interface RawFile {
|
||||
path: string;
|
||||
@@ -404,6 +404,7 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
|
||||
metadata,
|
||||
theme: "quiet-press",
|
||||
settings: { trim: "6x9", bleed: true },
|
||||
cover: createCover(),
|
||||
chapters,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,6 +16,42 @@ export interface BookSettings {
|
||||
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 {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -28,6 +64,7 @@ export interface Book {
|
||||
metadata: BookMetadata;
|
||||
theme: string;
|
||||
settings: BookSettings;
|
||||
cover: Cover;
|
||||
chapters: Chapter[];
|
||||
}
|
||||
|
||||
@@ -43,6 +80,14 @@ export function createChapter(title = "Untitled chapter"): Chapter {
|
||||
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 {
|
||||
return {
|
||||
schema: "margin/1",
|
||||
@@ -50,6 +95,7 @@ export function createBook(): Book {
|
||||
metadata: { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" },
|
||||
theme: "quiet-press",
|
||||
settings: { trim: "6x9", bleed: true },
|
||||
cover: createCover(),
|
||||
chapters: [createChapter("Chapter One")],
|
||||
};
|
||||
}
|
||||
|
||||
+27
-2
@@ -1,6 +1,15 @@
|
||||
import { create } from "zustand";
|
||||
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 {
|
||||
book: Book | null;
|
||||
@@ -12,8 +21,10 @@ interface BookState {
|
||||
setChapterContent: (id: string, content: JSONContent) => void;
|
||||
setChapterTitle: (id: string, title: string) => void;
|
||||
addChapter: () => void;
|
||||
moveChapter: (from: number, to: number) => void;
|
||||
setMetadata: (patch: Partial<BookMetadata>) => void;
|
||||
setSettings: (patch: Partial<BookSettings>) => void;
|
||||
setCover: (patch: Partial<Cover>) => void;
|
||||
markSaved: () => void;
|
||||
}
|
||||
|
||||
@@ -21,7 +32,10 @@ export const useBook = create<BookState>((set) => ({
|
||||
book: null,
|
||||
activeChapterId: "",
|
||||
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 }),
|
||||
setActiveChapter: (id) => set({ activeChapterId: id }),
|
||||
setChapterContent: (id, content) =>
|
||||
@@ -42,9 +56,20 @@ export const useBook = create<BookState>((set) => ({
|
||||
const chapter = createChapter();
|
||||
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) =>
|
||||
set((s) => (s.book ? { dirty: true, book: { ...s.book, metadata: { ...s.book.metadata, ...patch } } } : {})),
|
||||
setSettings: (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 }),
|
||||
}));
|
||||
@@ -193,6 +193,49 @@ button {
|
||||
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 {
|
||||
margin: 8px 18px 4px;
|
||||
padding: 9px 12px;
|
||||
@@ -277,6 +320,14 @@ button {
|
||||
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 {
|
||||
margin: 1.7rem 0;
|
||||
padding-left: 1.2rem;
|
||||
@@ -528,6 +579,80 @@ button {
|
||||
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 {
|
||||
margin: 1.8rem 0;
|
||||
}
|
||||
@@ -940,3 +1065,238 @@ button {
|
||||
.btn-primary:hover {
|
||||
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-dock: 384px;
|
||||
--measure: 34em;
|
||||
--measure: 46em;
|
||||
|
||||
--t-1: 11px;
|
||||
--t-2: 12px;
|
||||
|
||||
Reference in new issue
Block a user