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);
}
// 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 {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];

View file

@ -18,6 +18,7 @@ import {
duplicateChapter,
getChapterContent,
renameChapter,
setChapterColor,
reorderChapters,
listChaptersInOrder,
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[]) => {
try {
reorderChapters(requireBook(), newOrder);

View file

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

View file

@ -5,6 +5,9 @@ export interface ChapterEntry {
id: string;
title: 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 {

View file

@ -30,6 +30,7 @@ export type FolioAPI = {
getChapterContent: (id: string) => Promise<string | { error: string }>;
createChapter: (title: string) => Promise<any>;
renameChapter: (id: string, title: string) => Promise<any>;
setChapterColor: (id: string, color: string | null) => Promise<any>;
deleteChapter: (id: string) => Promise<any>;
duplicateChapter: (id: string, title?: string) => Promise<any>;
reorderChapters: (newOrder: string[]) => Promise<any>;
@ -74,6 +75,7 @@ interface ChapterEntry {
id: string;
title: string;
file: string;
color?: string;
}
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() {
chapterList.innerHTML = "";
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="row-title" title="${ch.title}">${ch.title}</span>
<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="dup" title="Duplicate">⧉</button>
<button class="icon-btn" data-act="del" title="Delete">×</button>
</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) => {
if (dragJustHappened) {
dragJustHappened = false;
@ -604,6 +713,10 @@ export function initBook(api: FolioAPI) {
li.focus();
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) => {
e.stopPropagation();
void rename(ch.id);

View file

@ -526,6 +526,67 @@ body.dragging-active * {
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 {
flex: 1;