diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 383227c..34f3c6d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4713,7 +4713,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "margin-app" -version = "0.1.5" +version = "0.1.6" dependencies = [ "base64 0.22.1", "fontdb", diff --git a/src/components/AddPageMenu.tsx b/src/components/AddPageMenu.tsx index 88f6899..4a62985 100644 --- a/src/components/AddPageMenu.tsx +++ b/src/components/AddPageMenu.tsx @@ -4,6 +4,7 @@ import { Icon } from "./Icon"; interface AddPageMenuProps { onAdd: (group: "front" | "back", title: string) => void; + onAddPart: () => void; } const GROUPS: { key: "front" | "back"; label: string }[] = [ @@ -11,7 +12,7 @@ const GROUPS: { key: "front" | "back"; label: string }[] = [ { key: "back", label: "Back matter" }, ]; -export function AddPageMenu({ onAdd }: AddPageMenuProps) { +export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) { const [open, setOpen] = useState(false); const [coords, setCoords] = useState({ top: 0, left: 0 }); const btnRef = useRef(null); @@ -42,6 +43,11 @@ export function AddPageMenu({ onAdd }: AddPageMenuProps) { onAdd(group, label); }; + const pickPart = () => { + setOpen(false); + onAddPart(); + }; + return ( <> + {GROUPS.map((g) => (
{g.label}
diff --git a/src/components/Dock.tsx b/src/components/Dock.tsx index 3c125d5..bb253b8 100644 --- a/src/components/Dock.tsx +++ b/src/components/Dock.tsx @@ -1,7 +1,7 @@ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { generateHTML } from "@tiptap/core"; 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 { chapterToPdfInputs, coverToPdfInputs } from "../export/typst"; import { compilePdf, isDesktop } from "../ipc"; @@ -71,7 +71,15 @@ function DockHead({ meta }: { meta?: ReactNode }) { function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string { 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() { @@ -163,7 +171,7 @@ function HtmlDock() {
{eyebrow}
- {!chapter.noTitle &&
{chapter.title || "Untitled"}
} + {previewTitle(chapter) !== undefined &&
{previewTitle(chapter)}
}
{idx * 8 + 7}
@@ -207,7 +215,7 @@ function DeviceDock({ device }: { device: Device }) { diff --git a/src/components/EditorView.tsx b/src/components/EditorView.tsx index b9ea252..3ccb8ca 100644 --- a/src/components/EditorView.tsx +++ b/src/components/EditorView.tsx @@ -18,7 +18,7 @@ import { useProofing } from "../store/useProofing"; import { useTheme } from "../store/useTheme"; import { useWidth } from "../store/useWidth"; import { WIDTH_OPTIONS } from "../width"; -import { bodyNumber, chapterKind } from "../model/book"; +import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book"; import { saveBook } from "../library"; import { isDesktop } from "../ipc"; import { issueSignature, rememberWord, runProof } from "../proofing"; @@ -156,7 +156,13 @@ export function EditorView() { const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id); const kind = chapter ? chapterKind(chapter) : "body"; 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 (
@@ -255,7 +261,9 @@ export function EditorView() { setChapterTitle(chapter.id, e.target.value)} diff --git a/src/components/RowMenu.tsx b/src/components/RowMenu.tsx index a8994eb..8b5ed59 100644 --- a/src/components/RowMenu.tsx +++ b/src/components/RowMenu.tsx @@ -6,11 +6,16 @@ interface RowMenuProps { label: string; onDuplicate?: () => void; onDelete: () => void; + onOpenChange?: (open: boolean) => void; className?: string; } -export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMenuProps) { +export function RowMenu({ label, onDuplicate, onDelete, onOpenChange, className = "" }: RowMenuProps) { const [open, setOpen] = useState(false); + + useEffect(() => { + onOpenChange?.(open); + }, [open]); const [coords, setCoords] = useState({ top: 0, right: 0 }); const btnRef = useRef(null); const popRef = useRef(null); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index dee6118..1b31c20 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; 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 { ConfirmDialog } from "./ConfirmDialog"; import { Icon } from "./Icon"; @@ -10,6 +10,8 @@ interface Row { chapter: Chapter; index: number; num: number | null; + part: number | null; + indent: boolean; } interface DropTarget { @@ -43,6 +45,7 @@ export function Sidebar() { const setActiveChapter = useBook((s) => s.setActiveChapter); const addChapter = useBook((s) => s.addChapter); const addPage = useBook((s) => s.addPage); + const addPart = useBook((s) => s.addPart); const duplicateChapter = useBook((s) => s.duplicateChapter); const deleteChapter = useBook((s) => s.deleteChapter); const moveChapter = useBook((s) => s.moveChapter); @@ -51,6 +54,7 @@ export function Sidebar() { const [dragIndex, setDragIndex] = useState(null); const [dropTarget, setDropTarget] = useState(null); const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null); + const [menuOpenId, setMenuOpenId] = useState(null); const [now, setNow] = useState(() => Date.now()); const dropRef = useRef(null); @@ -62,15 +66,23 @@ export function Sidebar() { 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[] }[] = [ { 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") }, ]; const groupEnd: Record = { front: groups[0].rows.length, body: groups[0].rows.length + groups[1].rows.length, + part: groups[0].rows.length + groups[1].rows.length, back: rows.length, }; @@ -125,7 +137,7 @@ export function Sidebar() { }; 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; gesture.current = { from: index, x: e.clientX, y: e.clientY, active: false }; window.addEventListener("pointermove", onPointerMove); @@ -154,7 +166,7 @@ export function Sidebar() { Cover - +
{groups.map((group) => @@ -162,12 +174,22 @@ export function Sidebar() {
{group.label}
    - {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 (
  • - {row.num ?? ""} + {!isPart && {row.num ?? ""}} - { - const el = e.currentTarget; - if (el.scrollWidth > el.clientWidth) el.title = row.chapter.title || "Untitled"; - else el.removeAttribute("title"); - }} - > - {row.chapter.title || "Untitled"} - + {isPart && Part {partRoman(row.part ?? 0)}} + {(!isPart || row.chapter.title) && ( + { + const el = e.currentTarget; + if (el.scrollWidth > el.clientWidth) el.title = row.chapter.title || "Untitled"; + else el.removeAttribute("title"); + }} + > + {row.chapter.title || "Untitled"} + + )} {row.chapter.id === activeChapterId && ( Edited {formatEdited(row.chapter.updatedAt, now)} )} + setMenuOpenId((cur) => (open ? row.chapter.id : cur === row.chapter.id ? null : cur)) + } onDuplicate={() => duplicateChapter(row.chapter.id)} - onDelete={() => setPendingDelete({ id: row.chapter.id, title: row.chapter.title || "Untitled" })} + onDelete={() => setPendingDelete({ id: row.chapter.id, title: rowLabel })} />
  • - ))} + ); + })} {!group.rows.length && (
  • Drop here diff --git a/src/export/epub.ts b/src/export/epub.ts index adfef1b..571eafb 100644 --- a/src/export/epub.ts +++ b/src/export/epub.ts @@ -1,6 +1,6 @@ import type { JSONContent } from "@tiptap/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"; export interface EpubFile { @@ -338,9 +338,12 @@ function contentOpf( function navXhtml(book: Book): string { const items = book.chapters .map((chapter, i) => { - if (chapter.noTitle && chapterKind(chapter) !== "body") return ""; - const fallback = chapterKind(chapter) === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : "Untitled"; - const label = chapter.noTitle ? fallback : chapter.title || fallback; + const kind = chapterKind(chapter); + if (chapter.noTitle && kind !== "body" && kind !== "part") return ""; + 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 `
  • ${esc(label)}
  • `; }) .filter(Boolean) @@ -366,16 +369,20 @@ function chapterXhtml(book: Book, index: number, paths: Map): st const chapter = book.chapters[index]; const kind = chapterKind(chapter); const num = bodyNumber(book.chapters, index); - const title = chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled"); - const eyebrow = kind === "body" ? `

    Chapter ${num}

    ` : ""; - const heading = chapter.noTitle ? "" : `

    ${esc(title)}

    `; + const partLabel = `Part ${partRoman(partNumber(book.chapters, index) ?? 0)}`; + const eyebrow = + kind === "body" ? `

    Chapter ${num}

    ` : kind === "part" ? `

    ${esc(partLabel)}

    ` : ""; + const headingText = kind === "part" ? chapter.title : chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled"); + const heading = chapter.noTitle || (kind === "part" && !chapter.title) ? "" : `

    ${esc(headingText)}

    `; + 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 ` - ${esc(title)} + ${esc(docTitle)} - +
    ${eyebrow} ${heading} @@ -435,6 +442,18 @@ h2, h3 { 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 { text-transform: uppercase; letter-spacing: 0.28em; diff --git a/src/export/typst.ts b/src/export/typst.ts index 542f72d..71ce682 100644 --- a/src/export/typst.ts +++ b/src/export/typst.ts @@ -1,5 +1,5 @@ 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 type { ImageInput } from "../ipc"; @@ -166,6 +166,20 @@ function preamble(book: Book): string { v(1.5em) } +#let openpart(info) = { + pagebreak(to: "odd", weak: true) + [#metadata(info) ] + 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 pat = loc.page-numbering() if pat == none { pat = "1" } @@ -186,6 +200,7 @@ function preamble(book: Book): string { let info = it.value let pg = pageof(it.location()) let body = info.kind == "body" + let part = info.kind == "part" let gap = if prev != none and prev != info.kind { 1.7em } else { 0.95em } prev = info.kind block(width: 100%, above: gap, below: 0.95em, { @@ -195,7 +210,9 @@ function preamble(book: Book): string { column-gutter: (0.65em, 1em), align: (right + top, left + top, right + top), if body { info.num } else { [] }, - if body { info.title } else { emph(info.title) }, + 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, )] }) @@ -312,16 +329,23 @@ function normTitle(title: string): string { function inToc(book: Book, index: number): boolean { 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"; - 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 { const chapter = book.chapters[index]; const kind = chapterKind(chapter); - const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : ""; 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"; 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 = new Map(), #set page(numbering: "i") #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 body = book.chapters .map((chapter, i) => { 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)}`; }) .join("\n\n"); diff --git a/src/model/book.ts b/src/model/book.ts index 0d6d675..dcff282 100644 --- a/src/model/book.ts +++ b/src/model/book.ts @@ -64,7 +64,7 @@ export function trimRatio(trim: TrimSize): number { return w / h; } -export type ChapterKind = "front" | "body" | "back"; +export type ChapterKind = "front" | "body" | "part" | "back"; export interface Chapter { id: string; @@ -107,6 +107,31 @@ export function bodyNumber(chapters: Chapter[], index: number): number | null { 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 { schema: "margin/1"; 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() }; } +export function createPart(title = ""): Chapter { + return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: "part", updatedAt: Date.now() }; +} + export function cloneChapter(chapter: Chapter): Chapter { const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`; return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() }; diff --git a/src/store/useBook.ts b/src/store/useBook.ts index 05cb38c..400482c 100644 --- a/src/store/useBook.ts +++ b/src/store/useBook.ts @@ -10,6 +10,7 @@ import { cloneChapter, createChapter, createPage, + createPart, normalizeBook, } from "../model/book"; import { loadActiveChapter, saveActiveChapter } from "../editor/positions"; @@ -34,6 +35,7 @@ interface BookState { setChapterNoTitle: (id: string, noTitle: boolean) => void; addChapter: () => void; addPage: (group: "front" | "back", title: string) => void; + addPart: () => void; duplicateChapter: (id: string) => void; deleteChapter: (id: string) => void; moveChapter: (from: number, to: number, toKind?: ChapterKind) => void; @@ -107,6 +109,18 @@ export const useBook = create((set, get) => ({ chapters.splice(insertAt, 0, page); 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) => set((s) => { if (!s.book) return {}; @@ -133,7 +147,7 @@ export const useBook = create((set, get) => ({ const chapters = [...s.book.chapters]; if (from < 0 || from >= chapters.length) return {}; 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)); if (from === dest && chapterKind(target) === nextKind) return {}; const [moved] = chapters.splice(from, 1); diff --git a/src/styles/app.css b/src/styles/app.css index 573b918..cbee76b 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -233,6 +233,37 @@ body.resizing { 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 { color: var(--accent); }