diff --git a/src/main/chapters.ts b/src/main/chapters.ts index dce5369..b8d1eb6 100644 --- a/src/main/chapters.ts +++ b/src/main/chapters.ts @@ -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]; diff --git a/src/main/index.ts b/src/main/index.ts index 6872804..85124b1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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); diff --git a/src/main/preload.ts b/src/main/preload.ts index f1a69b3..7d4607a 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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) => diff --git a/src/main/project.ts b/src/main/project.ts index 5a78569..da00a68 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -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 { diff --git a/src/renderer/book.ts b/src/renderer/book.ts index 20400ef..075f8bf 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -30,6 +30,7 @@ export type FolioAPI = { getChapterContent: (id: string) => Promise; createChapter: (title: string) => Promise; renameChapter: (id: string, title: string) => Promise; + setChapterColor: (id: string, color: string | null) => Promise; deleteChapter: (id: string) => Promise; duplicateChapter: (id: string, title?: string) => Promise; reorderChapters: (newOrder: string[]) => Promise; @@ -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) { ⋮ ${ch.title} + `; + 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); diff --git a/src/renderer/index.css b/src/renderer/index.css index 0650047..2dddb61 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -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; diff --git a/tests/run-chapters-test.mjs b/tests/run-chapters-test.mjs index f5d0244..c5dfe6d 100644 --- a/tests/run-chapters-test.mjs +++ b/tests/run-chapters-test.mjs @@ -39,6 +39,7 @@ const { duplicateChapter, reorderChapters, setChapterContent, + setChapterColor, moveInOrder, } = chapters; @@ -109,6 +110,30 @@ threw = false; try { reorderChapters(dir, ["bogus", order[0]]); } catch { threw = true; } 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 threw = false; try { createChapter(dir, " "); } catch { threw = true; }