Stage 3: chapter management — create/rename/delete/duplicate, sidebar list, select, read-only preview, word count, IPC + tests + e2e self-test
This commit is contained in:
parent
2862c433bd
commit
d3ab261346
11 changed files with 811 additions and 134 deletions
108
src/main/chapters.ts
Normal file
108
src/main/chapters.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
BookMeta,
|
||||
ChapterEntry,
|
||||
chapterFileName,
|
||||
chaptersDir,
|
||||
loadBook,
|
||||
makeChapterId,
|
||||
saveMeta,
|
||||
} from "./project.js";
|
||||
|
||||
export function listChaptersInOrder(meta: BookMeta): ChapterEntry[] {
|
||||
return meta.chapterOrder
|
||||
.map((id) => meta.chapters[id])
|
||||
.filter(Boolean) as ChapterEntry[];
|
||||
}
|
||||
|
||||
export function getChapterContent(bookPath: string, id: string): string {
|
||||
const meta = loadBook(bookPath);
|
||||
const entry = meta.chapters[id];
|
||||
if (!entry) throw new Error("Chapter not found");
|
||||
try {
|
||||
return fs.readFileSync(path.join(bookPath, entry.file), "utf-8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function createChapter(bookPath: string, title: string): ChapterEntry {
|
||||
if (!title.trim()) throw new Error("Chapter title cannot be empty");
|
||||
const meta = loadBook(bookPath);
|
||||
const id = makeChapterId(title);
|
||||
const file = path.posix.join("chapters", chapterFileName(id));
|
||||
const absFile = path.join(bookPath, file);
|
||||
let finalId = id;
|
||||
let finalFile = file;
|
||||
let counter = 2;
|
||||
while (meta.chapters[finalId]) {
|
||||
finalId = `${id}-${counter}`;
|
||||
finalFile = path.posix.join("chapters", chapterFileName(finalId));
|
||||
counter += 1;
|
||||
}
|
||||
meta.chapters[finalId] = { id: finalId, title, file: finalFile };
|
||||
meta.chapterOrder.push(finalId);
|
||||
saveMeta(bookPath, meta);
|
||||
fs.mkdirSync(path.join(bookPath, "chapters"), { recursive: true });
|
||||
fs.writeFileSync(path.join(bookPath, finalFile), "", "utf-8");
|
||||
return { id: finalId, title, file: finalFile };
|
||||
}
|
||||
|
||||
export function renameChapter(bookPath: string, id: string, newTitle: string): void {
|
||||
if (!newTitle.trim()) throw new Error("Chapter title cannot be empty");
|
||||
const meta = loadBook(bookPath);
|
||||
const entry = meta.chapters[id];
|
||||
if (!entry) throw new Error("Chapter not found");
|
||||
entry.title = newTitle;
|
||||
saveMeta(bookPath, meta);
|
||||
}
|
||||
|
||||
export function deleteChapter(bookPath: string, id: string): void {
|
||||
const meta = loadBook(bookPath);
|
||||
const entry = meta.chapters[id];
|
||||
if (!entry) throw new Error("Chapter not found");
|
||||
try {
|
||||
fs.rmSync(path.join(bookPath, entry.file), { force: true });
|
||||
} catch {
|
||||
/* file may be absent */
|
||||
}
|
||||
delete meta.chapters[id];
|
||||
meta.chapterOrder = meta.chapterOrder.filter((x) => x !== id);
|
||||
saveMeta(bookPath, meta);
|
||||
}
|
||||
|
||||
export function duplicateChapter(
|
||||
bookPath: string,
|
||||
id: string,
|
||||
title?: string
|
||||
): ChapterEntry {
|
||||
const meta = loadBook(bookPath);
|
||||
const src = meta.chapters[id];
|
||||
if (!src) throw new Error("Chapter not found");
|
||||
const baseTitle = title || `${src.title} (copy)`;
|
||||
const srcContent = getChapterContent(bookPath, id);
|
||||
let finalId = makeChapterId(baseTitle);
|
||||
let counter = 2;
|
||||
while (meta.chapters[finalId]) {
|
||||
finalId = `${makeChapterId(baseTitle)}-${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
const finalFile = path.posix.join("chapters", chapterFileName(finalId));
|
||||
meta.chapters[finalId] = { id: finalId, title: baseTitle, file: finalFile };
|
||||
meta.chapterOrder.push(finalId);
|
||||
saveMeta(bookPath, meta);
|
||||
fs.mkdirSync(path.join(bookPath, "chapters"), { recursive: true });
|
||||
fs.writeFileSync(path.join(bookPath, finalFile), srcContent, "utf-8");
|
||||
return { id: finalId, title: baseTitle, file: finalFile };
|
||||
}
|
||||
|
||||
export function reorderChapters(bookPath: string, newOrder: string[]): void {
|
||||
const meta = loadBook(bookPath);
|
||||
const valid = new Set(Object.keys(meta.chapters));
|
||||
if (!newOrder.every((id) => valid.has(id))) {
|
||||
throw new Error("Invalid chapter order");
|
||||
}
|
||||
meta.chapterOrder = newOrder;
|
||||
saveMeta(bookPath, meta);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
|
||||
import type { OpenDialogOptions } from "electron";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
BookResult,
|
||||
|
|
@ -11,6 +12,15 @@ import {
|
|||
} from "./project.js";
|
||||
import { listRecents, addRecent } from "./recents.js";
|
||||
import { buildMenu } from "./menu.js";
|
||||
import {
|
||||
createChapter,
|
||||
deleteChapter,
|
||||
duplicateChapter,
|
||||
getChapterContent,
|
||||
renameChapter,
|
||||
reorderChapters,
|
||||
listChaptersInOrder,
|
||||
} from "./chapters.js";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let bookPath: string | null = null;
|
||||
|
|
@ -42,9 +52,29 @@ function createWindow() {
|
|||
},
|
||||
});
|
||||
mainWindow.loadFile(path.join(__dirname, "index.html"));
|
||||
mainWindow.webContents.on("did-finish-load", () =>
|
||||
console.log("[folio] renderer loaded")
|
||||
);
|
||||
mainWindow.webContents.on("did-finish-load", () => {
|
||||
console.log("[folio] renderer loaded");
|
||||
if (process.env.FOLIO_SELF_TEST === "1") {
|
||||
const bookDir = process.env.FOLIO_TEST_BOOK;
|
||||
if (bookDir) {
|
||||
const scenarioPath = path.join(__dirname, "..", "tests", "selftest-scenario.js");
|
||||
const scenario = fs
|
||||
.readFileSync(scenarioPath, "utf-8")
|
||||
.replace(/__BOOKDIR_VALUE__/, JSON.stringify(bookDir));
|
||||
mainWindow?.webContents
|
||||
.executeJavaScript(scenario, false)
|
||||
.then((summary) => {
|
||||
console.log("[selftest] " + JSON.stringify(summary));
|
||||
app.quit();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("[selftest] ERROR", e);
|
||||
app.quit();
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
|
|
@ -134,3 +164,79 @@ ipcMain.handle("folio:closeBook", () => {
|
|||
getWindow()?.webContents.send("folio:bookClosed");
|
||||
return true;
|
||||
});
|
||||
|
||||
function requireBook(): string {
|
||||
if (!bookPath) throw new Error("No book is open.");
|
||||
return bookPath;
|
||||
}
|
||||
|
||||
ipcMain.handle("folio:getBookMeta", () => {
|
||||
try {
|
||||
return loadBook(requireBook());
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:getChapterContent", (_e, id: string) => {
|
||||
try {
|
||||
return getChapterContent(requireBook(), id);
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:createChapter", (_e, title: string) => {
|
||||
try {
|
||||
const entry = createChapter(requireBook(), title);
|
||||
return { id: entry.id, title: entry.title, file: entry.file };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:renameChapter", (_e, id: string, title: string) => {
|
||||
try {
|
||||
renameChapter(requireBook(), id, title);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:deleteChapter", (_e, id: string) => {
|
||||
try {
|
||||
deleteChapter(requireBook(), id);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:duplicateChapter", (_e, id: string, title?: string) => {
|
||||
try {
|
||||
const entry = duplicateChapter(requireBook(), id, title);
|
||||
return { id: entry.id, title: entry.title };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:reorderChapters", (_e, newOrder: string[]) => {
|
||||
try {
|
||||
reorderChapters(requireBook(), newOrder);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// expose ordered chapter list for the renderer
|
||||
ipcMain.handle("folio:getChapterList", () => {
|
||||
try {
|
||||
const meta = loadBook(requireBook());
|
||||
return listChaptersInOrder(meta);
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,30 @@
|
|||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
contextBridge.exposeInMainWorld("folio", {
|
||||
// book / project
|
||||
newBook: () => ipcRenderer.invoke("folio:newBook"),
|
||||
openBook: () => ipcRenderer.invoke("folio:openBook"),
|
||||
openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p),
|
||||
revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p),
|
||||
getRecents: () => ipcRenderer.invoke("folio:getRecents"),
|
||||
closeBook: () => ipcRenderer.invoke("folio:closeBook"),
|
||||
|
||||
// chapters
|
||||
getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"),
|
||||
getChapterList: () => ipcRenderer.invoke("folio:getChapterList"),
|
||||
getChapterContent: (id: string) => ipcRenderer.invoke("folio:getChapterContent", id),
|
||||
createChapter: (title: string) =>
|
||||
ipcRenderer.invoke("folio:createChapter", title),
|
||||
renameChapter: (id: string, title: string) =>
|
||||
ipcRenderer.invoke("folio:renameChapter", id, title),
|
||||
deleteChapter: (id: string) =>
|
||||
ipcRenderer.invoke("folio:deleteChapter", id),
|
||||
duplicateChapter: (id: string, title?: string) =>
|
||||
ipcRenderer.invoke("folio:duplicateChapter", id, title),
|
||||
reorderChapters: (newOrder: string[]) =>
|
||||
ipcRenderer.invoke("folio:reorderChapters", newOrder),
|
||||
|
||||
// events
|
||||
onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
|
||||
ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)),
|
||||
onBookClosed: (cb: () => void) =>
|
||||
|
|
|
|||
287
src/renderer/book.ts
Normal file
287
src/renderer/book.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import type { BookMeta } from "../main/project.js";
|
||||
|
||||
export type FolioAPI = {
|
||||
newBook: () => Promise<any>;
|
||||
openBook: () => Promise<any>;
|
||||
openBookPath: (p: string) => Promise<any>;
|
||||
revealInFolder: (p: string) => Promise<boolean>;
|
||||
getRecents: () => Promise<any[]>;
|
||||
closeBook: () => Promise<boolean>;
|
||||
getBookMeta: () => Promise<BookMeta | { error: string }>;
|
||||
getChapterList: () => Promise<any[]>;
|
||||
getChapterContent: (id: string) => Promise<string | { error: string }>;
|
||||
createChapter: (title: string) => Promise<any>;
|
||||
renameChapter: (id: string, title: string) => Promise<any>;
|
||||
deleteChapter: (id: string) => Promise<any>;
|
||||
duplicateChapter: (id: string, title?: string) => Promise<any>;
|
||||
reorderChapters: (newOrder: string[]) => Promise<any>;
|
||||
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
|
||||
onBookClosed: (cb: () => void) => void;
|
||||
onNewBookRequested: (cb: () => void) => void;
|
||||
onOpenBookRequested: (cb: () => void) => void;
|
||||
onOpenRecent: (cb: (p: string) => void) => void;
|
||||
onRevealRequested: (cb: (p: string) => void) => void;
|
||||
};
|
||||
|
||||
const $ = (id: string) => document.getElementById(id) as HTMLElement;
|
||||
|
||||
interface ChapterEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
interface Recent {
|
||||
path: string;
|
||||
title: string;
|
||||
lastOpened: string;
|
||||
}
|
||||
|
||||
export function initBook(api: FolioAPI) {
|
||||
let currentBook: { path: string; title: string } | null = null;
|
||||
let meta: BookMeta | null = null;
|
||||
let selectedId: string | null = null;
|
||||
|
||||
const chapterList = $("chapterList") as HTMLUListElement;
|
||||
const editorPlaceholder = $("editorPlaceholder");
|
||||
const chapterView = $("chapterView");
|
||||
const chapterTitleEl = $("chapterTitle");
|
||||
const chapterContentEl = $("chapterContent");
|
||||
const wordCountEl = $("wordCount");
|
||||
const projectBar = $("projectBar");
|
||||
const bookTitleEl = $("bookTitle");
|
||||
const bookPathEl = $("bookPath");
|
||||
const welcome = $("welcome");
|
||||
const newChapterBtn = $("newChapterBtn") as HTMLButtonElement;
|
||||
const recentList = $("recentList") as HTMLUListElement;
|
||||
|
||||
function showWelcome() {
|
||||
welcome.classList.remove("hidden");
|
||||
chapterView.classList.add("hidden");
|
||||
editorPlaceholder.classList.add("hidden");
|
||||
projectBar.classList.add("hidden");
|
||||
currentBook = null;
|
||||
meta = null;
|
||||
selectedId = null;
|
||||
renderChapterList();
|
||||
void renderRecents();
|
||||
}
|
||||
|
||||
function showPlaceholder() {
|
||||
chapterView.classList.add("hidden");
|
||||
editorPlaceholder.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function openBook(data: { path: string; title: string }) {
|
||||
currentBook = data;
|
||||
bookTitleEl.textContent = data.title;
|
||||
bookPathEl.textContent = data.path;
|
||||
bookPathEl.title = data.path;
|
||||
projectBar.classList.remove("hidden");
|
||||
welcome.classList.add("hidden");
|
||||
selectedId = null;
|
||||
void loadMeta();
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const res = await api.getBookMeta();
|
||||
if (res && typeof res === "object" && "error" in res) {
|
||||
alert((res as { error: string }).error);
|
||||
meta = null;
|
||||
} else {
|
||||
meta = res as BookMeta;
|
||||
}
|
||||
renderChapterList();
|
||||
showPlaceholder();
|
||||
}
|
||||
|
||||
async function refreshMeta() {
|
||||
await loadMeta();
|
||||
if (meta && meta.chapters[selectedId ?? ""]) {
|
||||
// keep selection if still valid
|
||||
} else {
|
||||
selectedId = null;
|
||||
showPlaceholder();
|
||||
}
|
||||
}
|
||||
|
||||
function renderChapterList() {
|
||||
chapterList.innerHTML = "";
|
||||
if (!meta) return;
|
||||
const ordered = meta.chapterOrder
|
||||
.map((id) => meta!.chapters[id])
|
||||
.filter(Boolean) as ChapterEntry[];
|
||||
for (const ch of ordered) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "chapter-row" + (selectedId === ch.id ? " active" : "");
|
||||
li.dataset.id = ch.id;
|
||||
li.innerHTML = `
|
||||
<span class="row-title" title="${ch.title}">${ch.title}</span>
|
||||
<span class="row-actions">
|
||||
<button class="icon-btn" data-act="rename" title="Rename">✎</button>
|
||||
<button class="icon-btn" data-act="dup" title="Duplicate">⧉</button>
|
||||
<button class="icon-btn" data-act="del" title="Delete">×</button>
|
||||
</span>`;
|
||||
li.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
void selectChapter(ch.id);
|
||||
});
|
||||
li.querySelector('[data-act="rename"]')?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
void rename(ch.id);
|
||||
});
|
||||
li.querySelector('[data-act="dup"]')?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
void duplicate(ch.id);
|
||||
});
|
||||
li.querySelector('[data-act="del"]')?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
void del(ch.id);
|
||||
});
|
||||
chapterList.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectChapter(id: string) {
|
||||
selectedId = id;
|
||||
renderChapterList();
|
||||
try {
|
||||
const content = await api.getChapterContent(id);
|
||||
const text = typeof content === "string" ? content : "";
|
||||
const title = (meta && meta.chapters[id]?.title) || id;
|
||||
chapterTitleEl.textContent = title;
|
||||
chapterContentEl.textContent = text;
|
||||
wordCountEl.textContent = `words: ${countWords(text)}`;
|
||||
chapterView.classList.remove("hidden");
|
||||
editorPlaceholder.classList.add("hidden");
|
||||
} catch {
|
||||
showPlaceholder();
|
||||
}
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return 0;
|
||||
return trimmed.split(/\s+/).length;
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!meta) return;
|
||||
const count = meta.chapterOrder.length + 1;
|
||||
const title = prompt("Chapter title", `Chapter ${count}`);
|
||||
if (!title || !title.trim()) return;
|
||||
const res = await api.createChapter(title.trim());
|
||||
if (res && "error" in res) {
|
||||
alert((res as { error: string }).error);
|
||||
} else if (res && "id" in res) {
|
||||
await refreshMeta();
|
||||
void selectChapter((res as ChapterEntry).id);
|
||||
}
|
||||
}
|
||||
|
||||
async function rename(id: string) {
|
||||
if (!meta || !meta.chapters[id]) return;
|
||||
const entry = meta.chapters[id];
|
||||
const title = prompt("Rename chapter", entry.title);
|
||||
if (!title || !title.trim()) return;
|
||||
const res = await api.renameChapter(id, title.trim());
|
||||
if (res && "error" in res) alert((res as { error: string }).error);
|
||||
else {
|
||||
await refreshMeta();
|
||||
if (selectedId === id && meta && meta.chapters[id]) {
|
||||
chapterTitleEl.textContent = meta.chapters[id].title;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function del(id: string) {
|
||||
if (!meta || !meta.chapters[id]) return;
|
||||
const entry = meta.chapters[id];
|
||||
if (!confirm(`Delete "${entry.title}"? This cannot be undone.`)) return;
|
||||
const res = await api.deleteChapter(id);
|
||||
if (res && "error" in res) alert((res as { error: string }).error);
|
||||
else {
|
||||
if (selectedId === id) {
|
||||
selectedId = null;
|
||||
showPlaceholder();
|
||||
}
|
||||
await refreshMeta();
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicate(id: string) {
|
||||
if (!meta || !meta.chapters[id]) return;
|
||||
const entry = meta.chapters[id];
|
||||
const title = prompt("Duplicate as:", `${entry.title} (copy)`);
|
||||
if (title === null) return;
|
||||
const res = await api.duplicateChapter(id, title.trim() || undefined);
|
||||
if (res && "error" in res) alert((res as { error: string }).error);
|
||||
else if (res && "id" in res) {
|
||||
await refreshMeta();
|
||||
void selectChapter((res as ChapterEntry).id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResult(res: any) {
|
||||
if (!res) return;
|
||||
if ("canceled" in res) return;
|
||||
if ("error" in res) {
|
||||
alert((res as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
if ("path" in res) openBook({ path: res.path, title: res.title });
|
||||
}
|
||||
|
||||
async function startNewBook() {
|
||||
const res = await api.newBook();
|
||||
await handleResult(res);
|
||||
await renderRecents();
|
||||
}
|
||||
|
||||
async function startOpenBook() {
|
||||
const res = await api.openBook();
|
||||
await handleResult(res);
|
||||
await renderRecents();
|
||||
}
|
||||
|
||||
async function openPath(p: string) {
|
||||
const res = await api.openBookPath(p);
|
||||
await handleResult(res);
|
||||
await renderRecents();
|
||||
}
|
||||
|
||||
async function renderRecents() {
|
||||
const recents = (await api.getRecents()) as Recent[];
|
||||
recentList.innerHTML = "";
|
||||
if (!recents.length) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "muted";
|
||||
li.textContent = "No recent books.";
|
||||
recentList.appendChild(li);
|
||||
return;
|
||||
}
|
||||
for (const r of recents) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = r.title || r.path;
|
||||
li.title = r.path;
|
||||
li.addEventListener("click", () => void openPath(r.path));
|
||||
recentList.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
|
||||
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
|
||||
newChapterBtn.addEventListener("click", () => void create());
|
||||
$("revealBtn").addEventListener("click", () => {
|
||||
if (currentBook) void api.revealInFolder(currentBook.path);
|
||||
});
|
||||
|
||||
api.onBookOpened((data) => openBook(data));
|
||||
api.onBookClosed(showWelcome);
|
||||
api.onNewBookRequested(() => void startNewBook());
|
||||
api.onOpenBookRequested(() => void startOpenBook());
|
||||
api.onOpenRecent((p) => void openPath(p));
|
||||
api.onRevealRequested((p) => void api.revealInFolder(p));
|
||||
|
||||
void renderRecents();
|
||||
}
|
||||
|
|
@ -283,3 +283,115 @@ html, body {
|
|||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ---- chapter list ---- */
|
||||
.chapter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.chapter-row:hover {
|
||||
opacity: 1;
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
border-color: var(--color-accent);
|
||||
border: 1px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.chapter-row.active {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chapter-row:hover .row-actions {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
padding: 2px 6px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.12s ease, background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ---- chapter view ---- */
|
||||
.chapter-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--gap);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chapter-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: var(--gap);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: var(--gap);
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.word-count {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.chapter-content {
|
||||
flex: 1;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chapter-view hidden" id="chapterView">
|
||||
<header class="chapter-header">
|
||||
<h2 class="chapter-title" id="chapterTitle"></h2>
|
||||
<span class="word-count" id="wordCount"></span>
|
||||
</header>
|
||||
<pre class="chapter-content" id="chapterContent"></pre>
|
||||
</div>
|
||||
<div class="editor-placeholder hidden" id="editorPlaceholder">
|
||||
<p>Select a chapter to begin writing.</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,5 @@
|
|||
type BookResultMsg = { path: string; title: string } | { canceled: true } | { error: string } | null;
|
||||
export {};
|
||||
|
||||
type FolioAPI = {
|
||||
newBook: () => Promise<BookResultMsg>;
|
||||
openBook: () => Promise<BookResultMsg>;
|
||||
openBookPath: (p: string) => Promise<BookResultMsg>;
|
||||
revealInFolder: (p: string) => Promise<boolean>;
|
||||
getRecents: () => Promise<any[]>;
|
||||
closeBook: () => Promise<boolean>;
|
||||
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
|
||||
onBookClosed: (cb: () => void) => void;
|
||||
onNewBookRequested: (cb: () => void) => void;
|
||||
onOpenBookRequested: (cb: () => void) => void;
|
||||
onOpenRecent: (cb: (p: string) => void) => void;
|
||||
onRevealRequested: (cb: (p: string) => void) => void;
|
||||
};
|
||||
import { initBook } from "./book.js";
|
||||
import type { FolioAPI } from "./book.js";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
@ -22,116 +7,6 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
const $ = (id: string) => document.getElementById(id) as HTMLElement;
|
||||
|
||||
const welcome = $("welcome");
|
||||
const placeholder = $("editorPlaceholder");
|
||||
const projectBar = $("projectBar");
|
||||
const bookTitle = $("bookTitle");
|
||||
const bookPath = $("bookPath");
|
||||
const revealBtn = $("revealBtn") as HTMLButtonElement;
|
||||
const newChapterBtn = $("newChapterBtn") as HTMLButtonElement;
|
||||
const recentList = $("recentList");
|
||||
|
||||
let currentBook: { path: string; title: string } | null = null;
|
||||
|
||||
function showBook() {
|
||||
welcome.classList.add("hidden");
|
||||
placeholder.classList.remove("hidden");
|
||||
projectBar.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function showWelcome() {
|
||||
welcome.classList.remove("hidden");
|
||||
placeholder.classList.add("hidden");
|
||||
projectBar.classList.add("hidden");
|
||||
}
|
||||
|
||||
function setCurrentBook(book: { path: string; title: string }) {
|
||||
currentBook = book;
|
||||
bookTitle.textContent = book.title;
|
||||
bookPath.textContent = book.path;
|
||||
bookPath.title = book.path;
|
||||
showBook();
|
||||
void refreshRecents();
|
||||
}
|
||||
|
||||
function clearBook() {
|
||||
currentBook = null;
|
||||
showWelcome();
|
||||
}
|
||||
|
||||
function handleResult(res: BookResultMsg) {
|
||||
if (!res) return;
|
||||
if ("canceled" in res) return;
|
||||
if ("error" in res) {
|
||||
alert(res.error);
|
||||
return;
|
||||
}
|
||||
setCurrentBook({ path: res.path, title: res.title });
|
||||
}
|
||||
|
||||
async function startNewBook() {
|
||||
const res = await window.folio.newBook();
|
||||
handleResult(res);
|
||||
}
|
||||
|
||||
async function startOpenBook() {
|
||||
const res = await window.folio.openBook();
|
||||
handleResult(res);
|
||||
}
|
||||
|
||||
async function openPath(p: string) {
|
||||
const res = await window.folio.openBookPath(p);
|
||||
handleResult(res);
|
||||
}
|
||||
|
||||
function renderRecents(recents: any[]) {
|
||||
recentList.innerHTML = "";
|
||||
if (!recents.length) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "muted";
|
||||
li.textContent = "No recent books.";
|
||||
recentList.appendChild(li);
|
||||
return;
|
||||
}
|
||||
for (const r of recents as { title: string; path: string }[]) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = r.title || r.path;
|
||||
li.title = r.path;
|
||||
li.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault(); // prevent sidebar blur/focus issues
|
||||
void openPath(r.path);
|
||||
});
|
||||
recentList.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRecents() {
|
||||
const recents = await window.folio.getRecents();
|
||||
renderRecents(recents);
|
||||
}
|
||||
|
||||
function setupButtons() {
|
||||
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
|
||||
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
|
||||
newChapterBtn.addEventListener("click", () => alert("Creating chapters is part of the next stage."));
|
||||
revealBtn.addEventListener("click", () => {
|
||||
if (currentBook) void window.folio.revealInFolder(currentBook.path);
|
||||
});
|
||||
}
|
||||
|
||||
function setupListeners() {
|
||||
window.folio.onBookOpened((data) => setCurrentBook({ path: data.path, title: data.title }));
|
||||
window.folio.onBookClosed(clearBook);
|
||||
window.folio.onNewBookRequested(() => void startNewBook());
|
||||
window.folio.onOpenBookRequested(() => void startOpenBook());
|
||||
window.folio.onOpenRecent((p) => void openPath(p));
|
||||
window.folio.onRevealRequested((p) => void window.folio.revealInFolder(p));
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
setupButtons();
|
||||
setupListeners();
|
||||
void refreshRecents();
|
||||
initBook(window.folio);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue