Add chapter color customization

- Store an optional accent color on each ChapterEntry so it persists with the
  chapter across switching, reordering, close/reopen, and restart
- Add a per-row color button that opens a palette popover (assign / change /
  remove) and shows a small color dot in the sidebar, readable in Light/Dark
- Expose setChapterColor through the main/preload IPC bridge
This commit is contained in:
avi 2026-08-20 11:21:14 -05:00
commit 946a2967db
7 changed files with 231 additions and 0 deletions

View file

@ -125,6 +125,22 @@ export function renameChapter(bookPath: string, id: string, newTitle: string): v
saveMeta(bookPath, meta); saveMeta(bookPath, meta);
} }
// Assign, change, or remove (null) a chapter's accent color. The color lives on
// the ChapterEntry, so it moves with the chapter on reorder and persists in the
// book metadata. Unknown ids throw so the caller can surface a clear error.
export function setChapterColor(
bookPath: string,
id: string,
color: string | null
): void {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found");
if (color === null) delete entry.color;
else entry.color = color;
saveMeta(bookPath, meta);
}
export function deleteChapter(bookPath: string, id: string): void { export function deleteChapter(bookPath: string, id: string): void {
const meta = loadBook(bookPath); const meta = loadBook(bookPath);
const entry = meta.chapters[id]; const entry = meta.chapters[id];

View file

@ -18,6 +18,7 @@ import {
duplicateChapter, duplicateChapter,
getChapterContent, getChapterContent,
renameChapter, renameChapter,
setChapterColor,
reorderChapters, reorderChapters,
listChaptersInOrder, listChaptersInOrder,
setChapterContent, setChapterContent,
@ -273,6 +274,16 @@ ipcMain.handle("folio:duplicateChapter", (_e, id: string, title?: string) => {
} }
}); });
ipcMain.handle("folio:setChapterColor", (_e, id: string, color: string | null) => {
try {
setChapterColor(requireBook(), id, color);
notifyChange();
return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:reorderChapters", (_e, newOrder: string[]) => { ipcMain.handle("folio:reorderChapters", (_e, newOrder: string[]) => {
try { try {
reorderChapters(requireBook(), newOrder); reorderChapters(requireBook(), newOrder);

View file

@ -19,6 +19,8 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.invoke("folio:createChapter", title), ipcRenderer.invoke("folio:createChapter", title),
renameChapter: (id: string, title: string) => renameChapter: (id: string, title: string) =>
ipcRenderer.invoke("folio:renameChapter", id, title), ipcRenderer.invoke("folio:renameChapter", id, title),
setChapterColor: (id: string, color: string | null) =>
ipcRenderer.invoke("folio:setChapterColor", id, color),
deleteChapter: (id: string) => deleteChapter: (id: string) =>
ipcRenderer.invoke("folio:deleteChapter", id), ipcRenderer.invoke("folio:deleteChapter", id),
duplicateChapter: (id: string, title?: string) => duplicateChapter: (id: string, title?: string) =>

View file

@ -5,6 +5,9 @@ export interface ChapterEntry {
id: string; id: string;
title: string; title: string;
file: string; file: string;
// Optional accent color (hex string) shown as a sidebar indicator. Stored on
// the chapter itself so it travels with reordering and persists in folio.json.
color?: string;
} }
export interface BookMeta { export interface BookMeta {

View file

@ -30,6 +30,7 @@ export type FolioAPI = {
getChapterContent: (id: string) => Promise<string | { error: string }>; getChapterContent: (id: string) => Promise<string | { error: string }>;
createChapter: (title: string) => Promise<any>; createChapter: (title: string) => Promise<any>;
renameChapter: (id: string, title: string) => Promise<any>; renameChapter: (id: string, title: string) => Promise<any>;
setChapterColor: (id: string, color: string | null) => Promise<any>;
deleteChapter: (id: string) => Promise<any>; deleteChapter: (id: string) => Promise<any>;
duplicateChapter: (id: string, title?: string) => Promise<any>; duplicateChapter: (id: string, title?: string) => Promise<any>;
reorderChapters: (newOrder: string[]) => Promise<any>; reorderChapters: (newOrder: string[]) => Promise<any>;
@ -74,6 +75,7 @@ interface ChapterEntry {
id: string; id: string;
title: string; title: string;
file: string; file: string;
color?: string;
} }
interface Recent { interface Recent {
@ -578,6 +580,105 @@ export function initBook(api: FolioAPI) {
} }
} }
// Predefined palette used for the chapter-color picker (kept in sync with the
// text/highlight palettes so the whole app feels consistent).
const CHAPTER_COLORS = [
"#e11d48", "#f97316", "#eab308", "#22c55e",
"#3b82f6", "#8b5cf6", "#78716c", "#18181b",
];
const HEX_RE = /^#[0-9a-fA-F]{3,8}$/;
let colorPopover: HTMLDivElement | null = null;
function closeColorPopover() {
if (colorPopover) colorPopover.classList.add("hidden");
}
function openColorPopover(
anchor: HTMLElement,
current: string | null,
onPick: (c: string | null) => void
) {
if (!colorPopover) {
colorPopover = document.createElement("div");
colorPopover.className = "color-popover hidden";
document.body.appendChild(colorPopover);
}
const pop = colorPopover;
pop.innerHTML = "";
const grid = document.createElement("div");
grid.className = "cp-grid";
for (const c of CHAPTER_COLORS) {
const sw = document.createElement("button");
sw.type = "button";
sw.className = "cp-swatch" + (current === c ? " active" : "");
sw.style.background = c;
sw.title = c;
sw.setAttribute("aria-label", c);
sw.addEventListener("click", (e) => {
e.stopPropagation();
closeColorPopover();
onPick(c);
});
grid.appendChild(sw);
}
pop.appendChild(grid);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "cp-remove";
remove.textContent = current ? "Remove color" : "No color set";
remove.disabled = !current;
remove.addEventListener("click", (e) => {
e.stopPropagation();
closeColorPopover();
onPick(null);
});
pop.appendChild(remove);
pop.classList.remove("hidden");
const pw = pop.offsetWidth;
const ph = pop.offsetHeight;
const rect = anchor.getBoundingClientRect();
let left = rect.left;
let top = rect.bottom + 4;
if (left + pw > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - pw - 8);
}
if (top + ph > window.innerHeight - 8) {
top = Math.max(8, rect.top - ph - 4);
}
pop.style.left = `${left}px`;
pop.style.top = `${top}px`;
}
// Click outside the popover closes it (the open button stops propagation so it
// doesn't immediately self-close).
document.addEventListener("click", (e) => {
if (colorPopover && !colorPopover.classList.contains("hidden") &&
!colorPopover.contains(e.target as Node)) {
closeColorPopover();
}
});
window.addEventListener("scroll", closeColorPopover, true);
window.addEventListener("resize", closeColorPopover);
async function applyChapterColor(id: string, color: string | null) {
const res = await api.setChapterColor(id, color);
if (res && "error" in res) {
await alertMessage("Could not set chapter color", (res as { error: string }).error);
return;
}
const entry = ordered.find((c) => c.id === id);
if (entry) {
if (color === null) delete entry.color;
else entry.color = color;
}
if (meta && meta.chapters[id]) {
if (color === null) delete meta.chapters[id].color;
else meta.chapters[id].color = color;
}
renderChapterList();
}
function renderChapterList() { function renderChapterList() {
chapterList.innerHTML = ""; chapterList.innerHTML = "";
for (const ch of ordered) { for (const ch of ordered) {
@ -590,10 +691,18 @@ export function initBook(api: FolioAPI) {
<span class="drag-handle" title="Drag to reorder">⋮</span> <span class="drag-handle" title="Drag to reorder">⋮</span>
<span class="row-title" title="${ch.title}">${ch.title}</span> <span class="row-title" title="${ch.title}">${ch.title}</span>
<span class="row-actions"> <span class="row-actions">
<button class="icon-btn" data-act="color" title="Chapter color">●</button>
<button class="icon-btn" data-act="rename" title="Rename">✎</button> <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="dup" title="Duplicate">⧉</button>
<button class="icon-btn" data-act="del" title="Delete">×</button> <button class="icon-btn" data-act="del" title="Delete">×</button>
</span>`; </span>`;
if (ch.color && HEX_RE.test(ch.color)) {
const dot = document.createElement("span");
dot.className = "row-color-dot";
dot.style.background = ch.color;
dot.title = "Chapter color: " + ch.color;
li.insertBefore(dot, li.querySelector(".row-title"));
}
li.addEventListener("click", (e) => { li.addEventListener("click", (e) => {
if (dragJustHappened) { if (dragJustHappened) {
dragJustHappened = false; dragJustHappened = false;
@ -604,6 +713,10 @@ export function initBook(api: FolioAPI) {
li.focus(); li.focus();
void selectChapter(ch.id); void selectChapter(ch.id);
}); });
li.querySelector('[data-act="color"]')?.addEventListener("click", (e) => {
e.stopPropagation();
openColorPopover(li, ch.color ?? null, (c) => void applyChapterColor(ch.id, c));
});
li.querySelector('[data-act="rename"]')?.addEventListener("click", (e) => { li.querySelector('[data-act="rename"]')?.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
void rename(ch.id); void rename(ch.id);

View file

@ -526,6 +526,67 @@ body.dragging-active * {
border-color: var(--color-on-accent-border); border-color: var(--color-on-accent-border);
} }
/* Small accent dot shown next to a chapter's title when it has a color. */
.row-color-dot {
flex: 0 0 auto;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);
}
/* Chapter color picker popover. */
.color-popover {
position: fixed;
z-index: 1000;
background: var(--color-panel);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 8px;
box-shadow: 0 8px 24px var(--color-shadow-strong);
}
.color-popover.hidden {
display: none;
}
.color-popover .cp-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
margin-bottom: 6px;
}
.color-popover .cp-swatch {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.25);
cursor: pointer;
padding: 0;
transition: transform 0.1s ease;
}
.color-popover .cp-swatch:hover {
transform: scale(1.12);
}
.color-popover .cp-swatch.active {
outline: 2px solid var(--color-text);
outline-offset: 1px;
}
.color-popover .cp-remove {
width: 100%;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
border-radius: calc(var(--radius) - 4px);
padding: 4px 6px;
font-size: 0.8rem;
cursor: pointer;
}
.color-popover .cp-remove:disabled {
opacity: 0.5;
cursor: default;
}
/* ---- chapter view ---- */ /* ---- chapter view ---- */
.chapter-view { .chapter-view {
flex: 1; flex: 1;

View file

@ -39,6 +39,7 @@ const {
duplicateChapter, duplicateChapter,
reorderChapters, reorderChapters,
setChapterContent, setChapterContent,
setChapterColor,
moveInOrder, moveInOrder,
} = chapters; } = chapters;
@ -109,6 +110,30 @@ threw = false;
try { reorderChapters(dir, ["bogus", order[0]]); } catch { threw = true; } try { reorderChapters(dir, ["bogus", order[0]]); } catch { threw = true; }
ok(threw, "reorder throws on invalid id"); ok(threw, "reorder throws on invalid id");
// ---- chapter color ----
const col = createChapter(dir, "Colorful");
setChapterColor(dir, col.id, "#22c55e");
ok(loadBook(dir).chapters[col.id].color === "#22c55e", "setChapterColor assigns color");
setChapterColor(dir, col.id, "#3b82f6");
ok(loadBook(dir).chapters[col.id].color === "#3b82f6", "setChapterColor changes color");
setChapterColor(dir, col.id, null);
ok(!("color" in loadBook(dir).chapters[col.id]), "setChapterColor removes color (null)");
// color travels with the chapter on reorder
setChapterColor(dir, col.id, "#e11d48");
const before = loadBook(dir).chapterOrder.slice();
reorderChapters(dir, [before[before.length - 1], ...before.slice(0, -1)]);
ok(loadBook(dir).chapters[col.id].color === "#e11d48", "color persists after reorder");
// duplicate does not inherit the source color
const colDup = duplicateChapter(dir, col.id);
ok(!("color" in loadBook(dir).chapters[colDup.id]), "duplicate starts without color");
// setChapterColor throws on unknown id
threw = false;
try { setChapterColor(dir, "nope", "#fff"); } catch { threw = true; }
ok(threw, "setChapterColor throws on unknown id");
// empty title throws // empty title throws
threw = false; threw = false;
try { createChapter(dir, " "); } catch { threw = true; } try { createChapter(dir, " "); } catch { threw = true; }