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);
|
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];
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue