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:
parent
786a65ae2a
commit
946a2967db
7 changed files with 231 additions and 0 deletions
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue