mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
changes
This commit is contained in:
1 parent
9e714c8997
commit
f47e822497
11 files changed
+216
-37
No files matched your search
Generated
+1
-1
@@ -4713,7 +4713,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "margin-app"
|
name = "margin-app"
|
||||||
version = "0.1.5"
|
version = "0.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"fontdb",
|
"fontdb",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Icon } from "./Icon";
|
|||||||
|
|
||||||
interface AddPageMenuProps {
|
interface AddPageMenuProps {
|
||||||
onAdd: (group: "front" | "back", title: string) => void;
|
onAdd: (group: "front" | "back", title: string) => void;
|
||||||
|
onAddPart: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUPS: { key: "front" | "back"; label: string }[] = [
|
const GROUPS: { key: "front" | "back"; label: string }[] = [
|
||||||
@@ -11,7 +12,7 @@ const GROUPS: { key: "front" | "back"; label: string }[] = [
|
|||||||
{ key: "back", label: "Back matter" },
|
{ key: "back", label: "Back matter" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AddPageMenu({ onAdd }: AddPageMenuProps) {
|
export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||||
const btnRef = useRef<HTMLButtonElement>(null);
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -42,6 +43,11 @@ export function AddPageMenu({ onAdd }: AddPageMenuProps) {
|
|||||||
onAdd(group, label);
|
onAdd(group, label);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pickPart = () => {
|
||||||
|
setOpen(false);
|
||||||
|
onAddPart();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button ref={btnRef} className="add-page" data-open={open} onClick={toggle}>
|
<button ref={btnRef} className="add-page" data-open={open} onClick={toggle}>
|
||||||
@@ -54,6 +60,12 @@ export function AddPageMenu({ onAdd }: AddPageMenuProps) {
|
|||||||
style={{ top: coords.top, left: coords.left }}
|
style={{ top: coords.top, left: coords.left }}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
<div className="add-page-group">
|
||||||
|
<div className="add-page-label">Body</div>
|
||||||
|
<button className="add-page-item" onClick={pickPart}>
|
||||||
|
Part divider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{GROUPS.map((g) => (
|
{GROUPS.map((g) => (
|
||||||
<div key={g.key} className="add-page-group">
|
<div key={g.key} className="add-page-group">
|
||||||
<div className="add-page-label">{g.label}</div>
|
<div className="add-page-label">{g.label}</div>
|
||||||
|
|||||||
+12
-4
@@ -1,7 +1,7 @@
|
|||||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { generateHTML } from "@tiptap/core";
|
import { generateHTML } from "@tiptap/core";
|
||||||
import { COVER_ID, useBook } from "../store/useBook";
|
import { COVER_ID, useBook } from "../store/useBook";
|
||||||
import { type Chapter, type TrimSize, bodyNumber, chapterKind } from "../model/book";
|
import { type Chapter, type TrimSize, bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
|
||||||
import { editorExtensions } from "../editor/extensions";
|
import { editorExtensions } from "../editor/extensions";
|
||||||
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
||||||
import { compilePdf, isDesktop } from "../ipc";
|
import { compilePdf, isDesktop } from "../ipc";
|
||||||
@@ -71,7 +71,15 @@ function DockHead({ meta }: { meta?: ReactNode }) {
|
|||||||
|
|
||||||
function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string {
|
function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string {
|
||||||
const kind = chapterKind(chapter);
|
const kind = chapterKind(chapter);
|
||||||
return kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
if (kind === "body") return `Chapter ${bodyNumber(chapters, idx) ?? ""}`;
|
||||||
|
if (kind === "part") return `Part ${partRoman(partNumber(chapters, idx) ?? 0)}`;
|
||||||
|
return kind === "front" ? "Front matter" : "Back matter";
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewTitle(chapter: Chapter): string | undefined {
|
||||||
|
if (chapter.noTitle) return undefined;
|
||||||
|
if (chapterKind(chapter) === "part") return chapter.title || undefined;
|
||||||
|
return chapter.title || "Untitled";
|
||||||
}
|
}
|
||||||
|
|
||||||
function useActiveChapter() {
|
function useActiveChapter() {
|
||||||
@@ -163,7 +171,7 @@ function HtmlDock() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="p-opener">
|
<div className="p-opener">
|
||||||
<div className="p-num">{eyebrow}</div>
|
<div className="p-num">{eyebrow}</div>
|
||||||
{!chapter.noTitle && <div className="p-title">{chapter.title || "Untitled"}</div>}
|
{previewTitle(chapter) !== undefined && <div className="p-title">{previewTitle(chapter)}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
|
<div className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
<div className="folio">{idx * 8 + 7}</div>
|
<div className="folio">{idx * 8 + 7}</div>
|
||||||
@@ -207,7 +215,7 @@ function DeviceDock({ device }: { device: Device }) {
|
|||||||
<DeviceFrame
|
<DeviceFrame
|
||||||
device={device}
|
device={device}
|
||||||
eyebrow={eyebrow}
|
eyebrow={eyebrow}
|
||||||
title={chapter.noTitle ? undefined : chapter.title || "Untitled"}
|
title={previewTitle(chapter)}
|
||||||
html={html}
|
html={html}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { useProofing } from "../store/useProofing";
|
|||||||
import { useTheme } from "../store/useTheme";
|
import { useTheme } from "../store/useTheme";
|
||||||
import { useWidth } from "../store/useWidth";
|
import { useWidth } from "../store/useWidth";
|
||||||
import { WIDTH_OPTIONS } from "../width";
|
import { WIDTH_OPTIONS } from "../width";
|
||||||
import { bodyNumber, chapterKind } from "../model/book";
|
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
|
||||||
import { saveBook } from "../library";
|
import { saveBook } from "../library";
|
||||||
import { isDesktop } from "../ipc";
|
import { isDesktop } from "../ipc";
|
||||||
import { issueSignature, rememberWord, runProof } from "../proofing";
|
import { issueSignature, rememberWord, runProof } from "../proofing";
|
||||||
@@ -156,7 +156,13 @@ export function EditorView() {
|
|||||||
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
|
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
|
||||||
const kind = chapter ? chapterKind(chapter) : "body";
|
const kind = chapter ? chapterKind(chapter) : "body";
|
||||||
const eyebrow =
|
const eyebrow =
|
||||||
kind === "body" ? `Chapter ${bodyNumber(book.chapters, realIdx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
kind === "body"
|
||||||
|
? `Chapter ${bodyNumber(book.chapters, realIdx) ?? ""}`
|
||||||
|
: kind === "part"
|
||||||
|
? `Part ${partRoman(partNumber(book.chapters, realIdx) ?? 0)}`
|
||||||
|
: kind === "front"
|
||||||
|
? "Front matter"
|
||||||
|
: "Back matter";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@@ -255,7 +261,9 @@ export function EditorView() {
|
|||||||
<input
|
<input
|
||||||
className="chapter-title-input"
|
className="chapter-title-input"
|
||||||
value={chapter.noTitle ? "" : chapter.title}
|
value={chapter.noTitle ? "" : chapter.title}
|
||||||
placeholder={chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : "Page title"}
|
placeholder={
|
||||||
|
chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
|
||||||
|
}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
disabled={chapter.noTitle}
|
disabled={chapter.noTitle}
|
||||||
onChange={(e) => setChapterTitle(chapter.id, e.target.value)}
|
onChange={(e) => setChapterTitle(chapter.id, e.target.value)}
|
||||||
|
|||||||
@@ -6,11 +6,16 @@ interface RowMenuProps {
|
|||||||
label: string;
|
label: string;
|
||||||
onDuplicate?: () => void;
|
onDuplicate?: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMenuProps) {
|
export function RowMenu({ label, onDuplicate, onDelete, onOpenChange, className = "" }: RowMenuProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onOpenChange?.(open);
|
||||||
|
}, [open]);
|
||||||
const [coords, setCoords] = useState({ top: 0, right: 0 });
|
const [coords, setCoords] = useState({ top: 0, right: 0 });
|
||||||
const btnRef = useRef<HTMLButtonElement>(null);
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
const popRef = useRef<HTMLDivElement>(null);
|
const popRef = useRef<HTMLDivElement>(null);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { COVER_ID, useBook } from "../store/useBook";
|
import { COVER_ID, useBook } from "../store/useBook";
|
||||||
import { type Chapter, type ChapterKind, bodyNumber, chapterKind } from "../model/book";
|
import { type Chapter, type ChapterKind, bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
|
||||||
import { AddPageMenu } from "./AddPageMenu";
|
import { AddPageMenu } from "./AddPageMenu";
|
||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
@@ -10,6 +10,8 @@ interface Row {
|
|||||||
chapter: Chapter;
|
chapter: Chapter;
|
||||||
index: number;
|
index: number;
|
||||||
num: number | null;
|
num: number | null;
|
||||||
|
part: number | null;
|
||||||
|
indent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DropTarget {
|
interface DropTarget {
|
||||||
@@ -43,6 +45,7 @@ export function Sidebar() {
|
|||||||
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 addPage = useBook((s) => s.addPage);
|
const addPage = useBook((s) => s.addPage);
|
||||||
|
const addPart = useBook((s) => s.addPart);
|
||||||
const duplicateChapter = useBook((s) => s.duplicateChapter);
|
const duplicateChapter = useBook((s) => s.duplicateChapter);
|
||||||
const deleteChapter = useBook((s) => s.deleteChapter);
|
const deleteChapter = useBook((s) => s.deleteChapter);
|
||||||
const moveChapter = useBook((s) => s.moveChapter);
|
const moveChapter = useBook((s) => s.moveChapter);
|
||||||
@@ -51,6 +54,7 @@ export function Sidebar() {
|
|||||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||||
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
|
||||||
|
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
const dropRef = useRef<DropTarget | null>(null);
|
const dropRef = useRef<DropTarget | null>(null);
|
||||||
@@ -62,15 +66,23 @@ export function Sidebar() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const rows: Row[] = chapters.map((chapter, index) => ({ chapter, index, num: bodyNumber(chapters, index) }));
|
const rows: Row[] = chapters.map((chapter, index) => ({
|
||||||
|
chapter,
|
||||||
|
index,
|
||||||
|
num: bodyNumber(chapters, index),
|
||||||
|
part: partNumber(chapters, index),
|
||||||
|
indent: chapterKind(chapter) === "body" && chapters.slice(0, index).some((c) => chapterKind(c) === "part"),
|
||||||
|
}));
|
||||||
|
const inBody = (r: Row) => chapterKind(r.chapter) === "body" || chapterKind(r.chapter) === "part";
|
||||||
const groups: { kind: ChapterKind; label: string; rows: Row[] }[] = [
|
const groups: { kind: ChapterKind; label: string; rows: Row[] }[] = [
|
||||||
{ kind: "front", label: "Front matter", rows: rows.filter((r) => chapterKind(r.chapter) === "front") },
|
{ kind: "front", label: "Front matter", rows: rows.filter((r) => chapterKind(r.chapter) === "front") },
|
||||||
{ kind: "body", label: "Chapters", rows: rows.filter((r) => chapterKind(r.chapter) === "body") },
|
{ kind: "body", label: "Chapters", rows: rows.filter(inBody) },
|
||||||
{ kind: "back", label: "Back matter", rows: rows.filter((r) => chapterKind(r.chapter) === "back") },
|
{ kind: "back", label: "Back matter", rows: rows.filter((r) => chapterKind(r.chapter) === "back") },
|
||||||
];
|
];
|
||||||
const groupEnd: Record<ChapterKind, number> = {
|
const groupEnd: Record<ChapterKind, number> = {
|
||||||
front: groups[0].rows.length,
|
front: groups[0].rows.length,
|
||||||
body: groups[0].rows.length + groups[1].rows.length,
|
body: groups[0].rows.length + groups[1].rows.length,
|
||||||
|
part: groups[0].rows.length + groups[1].rows.length,
|
||||||
back: rows.length,
|
back: rows.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,7 +137,7 @@ export function Sidebar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onRowPointerDown = (e: React.PointerEvent, index: number) => {
|
const onRowPointerDown = (e: React.PointerEvent, index: number) => {
|
||||||
if (e.button !== 0 || (e.target as HTMLElement).closest(".row-menu-btn")) return;
|
if (e.button !== 0 || chapters[index]?.id === menuOpenId || (e.target as HTMLElement).closest(".row-menu-btn")) return;
|
||||||
suppressClick.current = false;
|
suppressClick.current = false;
|
||||||
gesture.current = { from: index, x: e.clientX, y: e.clientY, active: false };
|
gesture.current = { from: index, x: e.clientX, y: e.clientY, active: false };
|
||||||
window.addEventListener("pointermove", onPointerMove);
|
window.addEventListener("pointermove", onPointerMove);
|
||||||
@@ -154,7 +166,7 @@ export function Sidebar() {
|
|||||||
<Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} />
|
<Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} />
|
||||||
<span className="title">Cover</span>
|
<span className="title">Cover</span>
|
||||||
</button>
|
</button>
|
||||||
<AddPageMenu onAdd={addPage} />
|
<AddPageMenu onAdd={addPage} onAddPart={addPart} />
|
||||||
|
|
||||||
<div className="nav-scroll">
|
<div className="nav-scroll">
|
||||||
{groups.map((group) =>
|
{groups.map((group) =>
|
||||||
@@ -162,12 +174,22 @@ export function Sidebar() {
|
|||||||
<div key={group.kind} className="nav-section" data-kind={group.kind}>
|
<div key={group.kind} className="nav-section" data-kind={group.kind}>
|
||||||
<div className="nav-label">{group.label}</div>
|
<div className="nav-label">{group.label}</div>
|
||||||
<ul className="chapters">
|
<ul className="chapters">
|
||||||
{group.rows.map((row, i) => (
|
{group.rows.map((row, i) => {
|
||||||
|
const isPart = chapterKind(row.chapter) === "part";
|
||||||
|
const partLabel = `Part ${partRoman(row.part ?? 0)}`;
|
||||||
|
const rowLabel = isPart
|
||||||
|
? row.chapter.title
|
||||||
|
? `${partLabel}: ${row.chapter.title}`
|
||||||
|
: partLabel
|
||||||
|
: row.chapter.title || "Untitled";
|
||||||
|
return (
|
||||||
<li
|
<li
|
||||||
key={row.chapter.id}
|
key={row.chapter.id}
|
||||||
className="chapter"
|
className="chapter"
|
||||||
data-idx={row.index}
|
data-idx={row.index}
|
||||||
data-kind={group.kind}
|
data-kind={group.kind}
|
||||||
|
data-part={isPart}
|
||||||
|
data-indent={row.indent}
|
||||||
data-active={row.chapter.id === activeChapterId}
|
data-active={row.chapter.id === activeChapterId}
|
||||||
data-dragging={dragIndex === row.index}
|
data-dragging={dragIndex === row.index}
|
||||||
data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index}
|
data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index}
|
||||||
@@ -178,8 +200,10 @@ export function Sidebar() {
|
|||||||
<span className="grip" title="Drag to reorder">
|
<span className="grip" title="Drag to reorder">
|
||||||
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} />
|
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} />
|
||||||
</span>
|
</span>
|
||||||
<span className="num">{row.num ?? ""}</span>
|
{!isPart && <span className="num">{row.num ?? ""}</span>}
|
||||||
<span className="text">
|
<span className="text">
|
||||||
|
{isPart && <span className="part-eyebrow">Part {partRoman(row.part ?? 0)}</span>}
|
||||||
|
{(!isPart || row.chapter.title) && (
|
||||||
<span
|
<span
|
||||||
className="title"
|
className="title"
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
@@ -190,17 +214,22 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
{row.chapter.title || "Untitled"}
|
{row.chapter.title || "Untitled"}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
{row.chapter.id === activeChapterId && (
|
{row.chapter.id === activeChapterId && (
|
||||||
<span className="meta">Edited {formatEdited(row.chapter.updatedAt, now)}</span>
|
<span className="meta">Edited {formatEdited(row.chapter.updatedAt, now)}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<RowMenu
|
<RowMenu
|
||||||
label="Page options"
|
label="Page options"
|
||||||
|
onOpenChange={(open) =>
|
||||||
|
setMenuOpenId((cur) => (open ? row.chapter.id : cur === row.chapter.id ? null : cur))
|
||||||
|
}
|
||||||
onDuplicate={() => duplicateChapter(row.chapter.id)}
|
onDuplicate={() => duplicateChapter(row.chapter.id)}
|
||||||
onDelete={() => setPendingDelete({ id: row.chapter.id, title: row.chapter.title || "Untitled" })}
|
onDelete={() => setPendingDelete({ id: row.chapter.id, title: rowLabel })}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{!group.rows.length && (
|
{!group.rows.length && (
|
||||||
<li className="chapter-drop" data-on={dropTarget?.kind === group.kind}>
|
<li className="chapter-drop" data-on={dropTarget?.kind === group.kind}>
|
||||||
Drop here
|
Drop here
|
||||||
|
|||||||
+28
-9
@@ -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, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement, partNumber, partRoman } from "../model/book";
|
||||||
import { fontStack, fontsUsed } from "../model/fonts";
|
import { fontStack, fontsUsed } from "../model/fonts";
|
||||||
|
|
||||||
export interface EpubFile {
|
export interface EpubFile {
|
||||||
@@ -338,9 +338,12 @@ function contentOpf(
|
|||||||
function navXhtml(book: Book): string {
|
function navXhtml(book: Book): string {
|
||||||
const items = book.chapters
|
const items = book.chapters
|
||||||
.map((chapter, i) => {
|
.map((chapter, i) => {
|
||||||
if (chapter.noTitle && chapterKind(chapter) !== "body") return "";
|
const kind = chapterKind(chapter);
|
||||||
const fallback = chapterKind(chapter) === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : "Untitled";
|
if (chapter.noTitle && kind !== "body" && kind !== "part") return "";
|
||||||
const label = chapter.noTitle ? fallback : chapter.title || fallback;
|
const partLabel = `Part ${partRoman(partNumber(book.chapters, i) ?? 0)}`;
|
||||||
|
const fallback = kind === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : kind === "part" ? partLabel : "Untitled";
|
||||||
|
const base = chapter.noTitle ? fallback : chapter.title || fallback;
|
||||||
|
const label = kind === "part" && !chapter.noTitle && chapter.title ? `${partLabel}: ${chapter.title}` : base;
|
||||||
return ` <li><a href="chapter-${i + 1}.xhtml">${esc(label)}</a></li>`;
|
return ` <li><a href="chapter-${i + 1}.xhtml">${esc(label)}</a></li>`;
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -366,16 +369,20 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
|
|||||||
const chapter = book.chapters[index];
|
const chapter = book.chapters[index];
|
||||||
const kind = chapterKind(chapter);
|
const kind = chapterKind(chapter);
|
||||||
const num = bodyNumber(book.chapters, index);
|
const num = bodyNumber(book.chapters, index);
|
||||||
const title = chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled");
|
const partLabel = `Part ${partRoman(partNumber(book.chapters, index) ?? 0)}`;
|
||||||
const eyebrow = kind === "body" ? `<p class="eyebrow">Chapter ${num}</p>` : "";
|
const eyebrow =
|
||||||
const heading = chapter.noTitle ? "" : `<h1>${esc(title)}</h1>`;
|
kind === "body" ? `<p class="eyebrow">Chapter ${num}</p>` : kind === "part" ? `<p class="eyebrow">${esc(partLabel)}</p>` : "";
|
||||||
|
const headingText = kind === "part" ? chapter.title : chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled");
|
||||||
|
const heading = chapter.noTitle || (kind === "part" && !chapter.title) ? "" : `<h1>${esc(headingText)}</h1>`;
|
||||||
|
const docTitle = chapter.title || (kind === "part" ? partLabel : kind === "body" ? `Chapter ${num}` : "Untitled");
|
||||||
|
const epubType = kind === "body" ? "chapter" : kind === "part" ? "part" : kind === "front" ? "frontmatter" : "backmatter";
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
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")}">
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
|
||||||
<head>
|
<head>
|
||||||
<title>${esc(title)}</title>
|
<title>${esc(docTitle)}</title>
|
||||||
<link rel="stylesheet" type="text/css" href="style.css"/>
|
<link rel="stylesheet" type="text/css" href="style.css"/>
|
||||||
</head>
|
</head>
|
||||||
<body epub:type="${kind === "body" ? "chapter" : kind === "front" ? "frontmatter" : "backmatter"}">
|
<body epub:type="${epubType}">
|
||||||
<header class="chapter-opener">
|
<header class="chapter-opener">
|
||||||
${eyebrow}
|
${eyebrow}
|
||||||
${heading}
|
${heading}
|
||||||
@@ -435,6 +442,18 @@ h2, h3 {
|
|||||||
margin: 3em 0 2.2em;
|
margin: 3em 0 2.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[epub:type="part"] {
|
||||||
|
page-break-before: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[epub:type="part"] .chapter-opener {
|
||||||
|
margin-top: 32vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[epub:type="part"] .eyebrow {
|
||||||
|
font-size: 0.82em;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.28em;
|
letter-spacing: 0.28em;
|
||||||
|
|||||||
+31
-7
@@ -1,5 +1,5 @@
|
|||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement, partNumber, partRoman } from "../model/book";
|
||||||
import { fontFamilyName, fontsUsed } from "../model/fonts";
|
import { fontFamilyName, fontsUsed } from "../model/fonts";
|
||||||
import type { ImageInput } from "../ipc";
|
import type { ImageInput } from "../ipc";
|
||||||
|
|
||||||
@@ -166,6 +166,20 @@ function preamble(book: Book): string {
|
|||||||
v(1.5em)
|
v(1.5em)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#let openpart(info) = {
|
||||||
|
pagebreak(to: "odd", weak: true)
|
||||||
|
[#metadata(info) <chap>]
|
||||||
|
v(3.1in)
|
||||||
|
align(center)[
|
||||||
|
#text(font: "Hanken Grotesk", size: 10pt, weight: "semibold", tracking: 0.34em)[#upper("Part " + info.numeral)]
|
||||||
|
#if not info.notitle and info.title != "" {
|
||||||
|
v(0.85em)
|
||||||
|
heading(level: 1, numbering: none, outlined: info.toc)[#info.title]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
v(1.5em)
|
||||||
|
}
|
||||||
|
|
||||||
#let pageof(loc) = {
|
#let pageof(loc) = {
|
||||||
let pat = loc.page-numbering()
|
let pat = loc.page-numbering()
|
||||||
if pat == none { pat = "1" }
|
if pat == none { pat = "1" }
|
||||||
@@ -186,6 +200,7 @@ function preamble(book: Book): string {
|
|||||||
let info = it.value
|
let info = it.value
|
||||||
let pg = pageof(it.location())
|
let pg = pageof(it.location())
|
||||||
let body = info.kind == "body"
|
let body = info.kind == "body"
|
||||||
|
let part = info.kind == "part"
|
||||||
let gap = if prev != none and prev != info.kind { 1.7em } else { 0.95em }
|
let gap = if prev != none and prev != info.kind { 1.7em } else { 0.95em }
|
||||||
prev = info.kind
|
prev = info.kind
|
||||||
block(width: 100%, above: gap, below: 0.95em, {
|
block(width: 100%, above: gap, below: 0.95em, {
|
||||||
@@ -195,7 +210,9 @@ function preamble(book: Book): string {
|
|||||||
column-gutter: (0.65em, 1em),
|
column-gutter: (0.65em, 1em),
|
||||||
align: (right + top, left + top, right + top),
|
align: (right + top, left + top, right + top),
|
||||||
if body { info.num } else { [] },
|
if body { info.num } else { [] },
|
||||||
if body { info.title } else { emph(info.title) },
|
if part {
|
||||||
|
strong(if info.title != "" { "Part " + info.numeral + ": " + info.title } else { "Part " + info.numeral })
|
||||||
|
} else if body { info.title } else { emph(info.title) },
|
||||||
pg,
|
pg,
|
||||||
)]
|
)]
|
||||||
})
|
})
|
||||||
@@ -312,16 +329,23 @@ function normTitle(title: string): string {
|
|||||||
|
|
||||||
function inToc(book: Book, index: number): boolean {
|
function inToc(book: Book, index: number): boolean {
|
||||||
const chapter = book.chapters[index];
|
const chapter = book.chapters[index];
|
||||||
if (chapter.noTitle && chapterKind(chapter) !== "body") return false;
|
const kind = chapterKind(chapter);
|
||||||
|
if (kind === "part") return true;
|
||||||
|
if (chapter.noTitle && kind !== "body") return false;
|
||||||
const title = cleanTitle(chapter.title) || "Untitled";
|
const title = cleanTitle(chapter.title) || "Untitled";
|
||||||
return !(chapterKind(chapter) === "front" && normTitle(title) === normTitle(book.metadata.title));
|
return !(kind === "front" && normTitle(title) === normTitle(book.metadata.title));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openerCall(book: Book, index: number): string {
|
function openerCall(book: Book, index: number): string {
|
||||||
const chapter = book.chapters[index];
|
const chapter = book.chapters[index];
|
||||||
const kind = chapterKind(chapter);
|
const kind = chapterKind(chapter);
|
||||||
const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
|
|
||||||
const notitle = !!chapter.noTitle;
|
const notitle = !!chapter.noTitle;
|
||||||
|
if (kind === "part") {
|
||||||
|
const numeral = partRoman(partNumber(book.chapters, index) ?? 0);
|
||||||
|
const title = notitle ? "" : cleanTitle(chapter.title);
|
||||||
|
return `#openpart((kind: "part", numeral: ${str(numeral)}, title: ${str(title)}, notitle: ${notitle}, toc: ${inToc(book, index)}))`;
|
||||||
|
}
|
||||||
|
const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
|
||||||
const title = notitle ? "" : cleanTitle(chapter.title) || "Untitled";
|
const title = notitle ? "" : cleanTitle(chapter.title) || "Untitled";
|
||||||
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, notitle: ${notitle}, toc: ${inToc(book, index)}))`;
|
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, notitle: ${notitle}, toc: ${inToc(book, index)}))`;
|
||||||
}
|
}
|
||||||
@@ -335,13 +359,13 @@ export function bookToTypst(book: Book, paths: Map<string, string> = new Map(),
|
|||||||
#set page(numbering: "i")
|
#set page(numbering: "i")
|
||||||
#counter(page).update(1)`;
|
#counter(page).update(1)`;
|
||||||
|
|
||||||
const firstBody = book.chapters.findIndex((c) => chapterKind(c) === "body");
|
const firstMain = book.chapters.findIndex((c) => chapterKind(c) === "body" || chapterKind(c) === "part");
|
||||||
const firstToc = book.chapters.findIndex((_, i) => inToc(book, i));
|
const firstToc = book.chapters.findIndex((_, i) => inToc(book, i));
|
||||||
|
|
||||||
const body = book.chapters
|
const body = book.chapters
|
||||||
.map((chapter, i) => {
|
.map((chapter, i) => {
|
||||||
const toc = i === firstToc ? `#contents()\n\n` : "";
|
const toc = i === firstToc ? `#contents()\n\n` : "";
|
||||||
const reset = i === firstBody ? `#set page(numbering: "1")\n#counter(page).update(1)\n` : "";
|
const reset = i === firstMain ? `#set page(numbering: "1")\n#counter(page).update(1)\n` : "";
|
||||||
return `${toc}${reset}${openerCall(book, i)}\n\n${chapterBody(chapter.content, paths)}`;
|
return `${toc}${reset}${openerCall(book, i)}\n\n${chapterBody(chapter.content, paths)}`;
|
||||||
})
|
})
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|||||||
+30
-1
@@ -64,7 +64,7 @@ export function trimRatio(trim: TrimSize): number {
|
|||||||
return w / h;
|
return w / h;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChapterKind = "front" | "body" | "back";
|
export type ChapterKind = "front" | "body" | "part" | "back";
|
||||||
|
|
||||||
export interface Chapter {
|
export interface Chapter {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -107,6 +107,31 @@ export function bodyNumber(chapters: Chapter[], index: number): number | null {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function partNumber(chapters: Chapter[], index: number): number | null {
|
||||||
|
if (index < 0 || index >= chapters.length || chapterKind(chapters[index]) !== "part") return null;
|
||||||
|
let n = 0;
|
||||||
|
for (let i = 0; i <= index; i++) if (chapterKind(chapters[i]) === "part") n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROMAN: [number, string][] = [
|
||||||
|
[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"],
|
||||||
|
[50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function partRoman(n: number): string {
|
||||||
|
if (n < 1) return String(n);
|
||||||
|
let num = n;
|
||||||
|
let out = "";
|
||||||
|
for (const [value, sym] of ROMAN) {
|
||||||
|
while (num >= value) {
|
||||||
|
out += sym;
|
||||||
|
num -= value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Book {
|
export interface Book {
|
||||||
schema: "margin/1";
|
schema: "margin/1";
|
||||||
id: string;
|
id: string;
|
||||||
@@ -141,6 +166,10 @@ export function createPage(group: "front" | "back", title: string): Chapter {
|
|||||||
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: group, updatedAt: Date.now() };
|
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: group, updatedAt: Date.now() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createPart(title = ""): Chapter {
|
||||||
|
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: "part", updatedAt: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
export function cloneChapter(chapter: Chapter): Chapter {
|
export function cloneChapter(chapter: Chapter): Chapter {
|
||||||
const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`;
|
const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`;
|
||||||
return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() };
|
return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() };
|
||||||
|
|||||||
+15
-1
@@ -10,6 +10,7 @@ import {
|
|||||||
cloneChapter,
|
cloneChapter,
|
||||||
createChapter,
|
createChapter,
|
||||||
createPage,
|
createPage,
|
||||||
|
createPart,
|
||||||
normalizeBook,
|
normalizeBook,
|
||||||
} from "../model/book";
|
} from "../model/book";
|
||||||
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
|
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
|
||||||
@@ -34,6 +35,7 @@ interface BookState {
|
|||||||
setChapterNoTitle: (id: string, noTitle: boolean) => void;
|
setChapterNoTitle: (id: string, noTitle: boolean) => void;
|
||||||
addChapter: () => void;
|
addChapter: () => void;
|
||||||
addPage: (group: "front" | "back", title: string) => void;
|
addPage: (group: "front" | "back", title: string) => void;
|
||||||
|
addPart: () => void;
|
||||||
duplicateChapter: (id: string) => void;
|
duplicateChapter: (id: string) => void;
|
||||||
deleteChapter: (id: string) => void;
|
deleteChapter: (id: string) => void;
|
||||||
moveChapter: (from: number, to: number, toKind?: ChapterKind) => void;
|
moveChapter: (from: number, to: number, toKind?: ChapterKind) => void;
|
||||||
@@ -107,6 +109,18 @@ export const useBook = create<BookState>((set, get) => ({
|
|||||||
chapters.splice(insertAt, 0, page);
|
chapters.splice(insertAt, 0, page);
|
||||||
return { activeChapterId: page.id, dirty: true, book: { ...s.book, chapters } };
|
return { activeChapterId: page.id, dirty: true, book: { ...s.book, chapters } };
|
||||||
}),
|
}),
|
||||||
|
addPart: () =>
|
||||||
|
set((s) => {
|
||||||
|
if (!s.book) return {};
|
||||||
|
const part = createPart();
|
||||||
|
const chapters = [...s.book.chapters];
|
||||||
|
const bodyEnd = chapters.length - chapters.filter((c) => chapterKind(c) === "back").length;
|
||||||
|
const activeIdx = chapters.findIndex((c) => c.id === s.activeChapterId);
|
||||||
|
const activeKind = activeIdx >= 0 ? chapterKind(chapters[activeIdx]) : null;
|
||||||
|
const insertAt = activeKind === "body" || activeKind === "part" ? activeIdx + 1 : bodyEnd;
|
||||||
|
chapters.splice(insertAt, 0, part);
|
||||||
|
return { activeChapterId: part.id, dirty: true, book: { ...s.book, chapters } };
|
||||||
|
}),
|
||||||
duplicateChapter: (id) =>
|
duplicateChapter: (id) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
if (!s.book) return {};
|
if (!s.book) return {};
|
||||||
@@ -133,7 +147,7 @@ export const useBook = create<BookState>((set, get) => ({
|
|||||||
const chapters = [...s.book.chapters];
|
const chapters = [...s.book.chapters];
|
||||||
if (from < 0 || from >= chapters.length) return {};
|
if (from < 0 || from >= chapters.length) return {};
|
||||||
const target = chapters[from];
|
const target = chapters[from];
|
||||||
const nextKind = toKind ?? chapterKind(target);
|
const nextKind = chapterKind(target) === "part" ? "part" : toKind ?? chapterKind(target);
|
||||||
const dest = Math.max(0, Math.min(from < to ? to - 1 : to, chapters.length - 1));
|
const dest = Math.max(0, Math.min(from < to ? to - 1 : to, chapters.length - 1));
|
||||||
if (from === dest && chapterKind(target) === nextKind) return {};
|
if (from === dest && chapterKind(target) === nextKind) return {};
|
||||||
const [moved] = chapters.splice(from, 1);
|
const [moved] = chapters.splice(from, 1);
|
||||||
|
|||||||
@@ -233,6 +233,37 @@ body.resizing {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chapter[data-part="true"] {
|
||||||
|
margin-top: 7px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter[data-part="true"] .text {
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter .part-eyebrow {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-1);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter[data-part="true"][data-active="true"] .part-eyebrow {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter[data-part="true"] .title {
|
||||||
|
font-size: var(--t-2);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter[data-indent="true"] {
|
||||||
|
padding-left: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
.chapter[data-active="true"] .num {
|
.chapter[data-active="true"] .num {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user