Stage 4: drag-and-drop + keyboard chapter reordering (moveInList), chapters-changed broadcast, DnD/keyboard e2e

This commit is contained in:
avi 2026-08-16 14:43:42 -05:00
commit 4b8d78458c
7 changed files with 217 additions and 34 deletions

View file

@ -106,3 +106,11 @@ export function reorderChapters(bookPath: string, newOrder: string[]): void {
meta.chapterOrder = newOrder;
saveMeta(bookPath, meta);
}
export function moveInOrder<T>(items: T[], fromIndex: number, toIndex: number): T[] {
if (fromIndex === toIndex) return items;
const copy = items.slice();
const [moved] = copy.splice(fromIndex, 1);
copy.splice(toIndex, 0, moved);
return copy;
}

View file

@ -103,6 +103,10 @@ function refreshMenu() {
);
}
function notifyChange() {
getWindow()?.webContents.send("folio:chapters-changed");
}
async function openBookAt(p: string): Promise<BookResult | ErrResult> {
try {
const meta = loadBook(p);
@ -189,6 +193,7 @@ ipcMain.handle("folio:getChapterContent", (_e, id: string) => {
ipcMain.handle("folio:createChapter", (_e, title: string) => {
try {
const entry = createChapter(requireBook(), title);
notifyChange();
return { id: entry.id, title: entry.title, file: entry.file };
} catch (e) {
return { error: (e as Error).message };
@ -198,6 +203,7 @@ ipcMain.handle("folio:createChapter", (_e, title: string) => {
ipcMain.handle("folio:renameChapter", (_e, id: string, title: string) => {
try {
renameChapter(requireBook(), id, title);
notifyChange();
return { ok: true };
} catch (e) {
return { error: (e as Error).message };
@ -207,6 +213,7 @@ ipcMain.handle("folio:renameChapter", (_e, id: string, title: string) => {
ipcMain.handle("folio:deleteChapter", (_e, id: string) => {
try {
deleteChapter(requireBook(), id);
notifyChange();
return { ok: true };
} catch (e) {
return { error: (e as Error).message };
@ -216,6 +223,7 @@ ipcMain.handle("folio:deleteChapter", (_e, id: string) => {
ipcMain.handle("folio:duplicateChapter", (_e, id: string, title?: string) => {
try {
const entry = duplicateChapter(requireBook(), id, title);
notifyChange();
return { id: entry.id, title: entry.title };
} catch (e) {
return { error: (e as Error).message };
@ -225,6 +233,7 @@ ipcMain.handle("folio:duplicateChapter", (_e, id: string, title?: string) => {
ipcMain.handle("folio:reorderChapters", (_e, newOrder: string[]) => {
try {
reorderChapters(requireBook(), newOrder);
notifyChange();
return { ok: true };
} catch (e) {
return { error: (e as Error).message };

View file

@ -29,6 +29,8 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)),
onBookClosed: (cb: () => void) =>
ipcRenderer.on("folio:bookClosed", () => cb()),
onChaptersChanged: (cb: () => void) =>
ipcRenderer.on("folio:chapters-changed", () => cb()),
onNewBookRequested: (cb: () => void) =>
ipcRenderer.on("folio:new-book-requested", () => cb()),
onOpenBookRequested: (cb: () => void) =>

View file

@ -17,6 +17,7 @@ export type FolioAPI = {
reorderChapters: (newOrder: string[]) => Promise<any>;
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
onBookClosed: (cb: () => void) => void;
onChaptersChanged: (cb: () => void) => void;
onNewBookRequested: (cb: () => void) => void;
onOpenBookRequested: (cb: () => void) => void;
onOpenRecent: (cb: (p: string) => void) => void;
@ -37,9 +38,18 @@ interface Recent {
lastOpened: string;
}
function moveInList<T>(items: T[], fromIndex: number, toIndex: number): T[] {
if (fromIndex === toIndex) return items;
const copy = items.slice();
const [moved] = copy.splice(fromIndex, 1);
copy.splice(toIndex, 0, moved);
return copy;
}
export function initBook(api: FolioAPI) {
let currentBook: { path: string; title: string } | null = null;
let meta: BookMeta | null = null;
let ordered: ChapterEntry[] = [];
let selectedId: string | null = null;
const chapterList = $("chapterList") as HTMLUListElement;
@ -62,9 +72,9 @@ export function initBook(api: FolioAPI) {
projectBar.classList.add("hidden");
currentBook = null;
meta = null;
ordered = [];
selectedId = null;
renderChapterList();
void renderRecents();
}
function showPlaceholder() {
@ -72,7 +82,7 @@ export function initBook(api: FolioAPI) {
editorPlaceholder.classList.remove("hidden");
}
function openBook(data: { path: string; title: string }) {
function setCurrentBook(data: { path: string; title: string }) {
currentBook = data;
bookTitleEl.textContent = data.title;
bookPathEl.textContent = data.path;
@ -90,16 +100,18 @@ export function initBook(api: FolioAPI) {
meta = null;
} else {
meta = res as BookMeta;
ordered = meta.chapterOrder
.map((id) => meta!.chapters[id])
.filter(Boolean) as ChapterEntry[];
}
renderChapterList();
showPlaceholder();
if (ordered.length) showPlaceholder();
else showPlaceholder();
}
async function refreshMeta() {
await loadMeta();
if (meta && meta.chapters[selectedId ?? ""]) {
// keep selection if still valid
} else {
if (meta && ordered.length === 0) {
selectedId = null;
showPlaceholder();
}
@ -107,14 +119,12 @@ export function initBook(api: FolioAPI) {
function renderChapterList() {
chapterList.innerHTML = "";
if (!meta) return;
const ordered = meta.chapterOrder
.map((id) => meta!.chapters[id])
.filter(Boolean) as ChapterEntry[];
for (const ch of ordered) {
const li = document.createElement("li");
li.className = "chapter-row" + (selectedId === ch.id ? " active" : "");
li.dataset.id = ch.id;
li.draggable = true;
li.tabIndex = -1;
li.innerHTML = `
<span class="row-title" title="${ch.title}">${ch.title}</span>
<span class="row-actions">
@ -124,6 +134,7 @@ export function initBook(api: FolioAPI) {
</span>`;
li.addEventListener("click", (e) => {
e.stopPropagation();
li.focus();
void selectChapter(ch.id);
});
li.querySelector('[data-act="rename"]')?.addEventListener("click", (e) => {
@ -138,6 +149,11 @@ export function initBook(api: FolioAPI) {
e.stopPropagation();
void del(ch.id);
});
li.addEventListener("keydown", (e) => {
if (!e.altKey) return;
if (e.key === "ArrowDown") { e.preventDefault(); moveSelectedDown(); }
else if (e.key === "ArrowUp") { e.preventDefault(); moveSelectedUp(); }
});
chapterList.appendChild(li);
}
}
@ -171,11 +187,10 @@ export function initBook(api: FolioAPI) {
const title = prompt("Chapter title", `Chapter ${count}`);
if (!title || !title.trim()) return;
const res = await api.createChapter(title.trim());
if (res && "error" in res) {
alert((res as { error: string }).error);
} else if (res && "id" in res) {
if (res && "error" in res) alert((res as { error: string }).error);
else {
await refreshMeta();
void selectChapter((res as ChapterEntry).id);
if (res && "id" in res) void selectChapter((res as ChapterEntry).id);
}
}
@ -201,10 +216,7 @@ export function initBook(api: FolioAPI) {
const res = await api.deleteChapter(id);
if (res && "error" in res) alert((res as { error: string }).error);
else {
if (selectedId === id) {
selectedId = null;
showPlaceholder();
}
if (selectedId === id) { selectedId = null; showPlaceholder(); }
await refreshMeta();
}
}
@ -222,36 +234,61 @@ export function initBook(api: FolioAPI) {
}
}
async function handleResult(res: any) {
async function persistOrder() {
if (!meta) return;
meta.chapterOrder = ordered.map((c) => c.id);
const res = await api.reorderChapters(meta.chapterOrder);
if (res && "error" in res) alert((res as { error: string }).error);
else renderChapterList();
}
function moveSelectedDown() {
if (!selectedId) return;
const i = ordered.findIndex((c) => c.id === selectedId);
if (i < 0 || i >= ordered.length - 1) return;
ordered = moveInList(ordered, i, i + 1);
meta!.chapterOrder = ordered.map((c) => c.id);
renderChapterList();
void persistOrder();
}
function moveSelectedUp() {
if (!selectedId) return;
const i = ordered.findIndex((c) => c.id === selectedId);
if (i <= 0) return;
ordered = moveInList(ordered, i, i - 1);
meta!.chapterOrder = ordered.map((c) => c.id);
renderChapterList();
void persistOrder();
}
function handleResult(res: any) {
if (!res) return;
if ("canceled" in res) return;
if ("error" in res) {
alert((res as { error: string }).error);
return;
}
if ("path" in res) openBook({ path: res.path, title: res.title });
if ("error" in res) { alert((res as { error: string }).error); return; }
if ("path" in res) setCurrentBook({ path: res.path, title: res.title });
}
async function startNewBook() {
const res = await api.newBook();
await handleResult(res);
handleResult(res);
await renderRecents();
}
async function startOpenBook() {
const res = await api.openBook();
await handleResult(res);
handleResult(res);
await renderRecents();
}
async function openPath(p: string) {
const res = await api.openBookPath(p);
await handleResult(res);
handleResult(res);
await renderRecents();
}
async function renderRecents() {
const recents = (await api.getRecents()) as Recent[];
const recents = await api.getRecents();
recentList.innerHTML = "";
if (!recents.length) {
const li = document.createElement("li");
@ -260,15 +297,77 @@ export function initBook(api: FolioAPI) {
recentList.appendChild(li);
return;
}
for (const r of recents) {
for (const r of recents as Recent[]) {
const li = document.createElement("li");
li.textContent = r.title || r.path;
li.title = r.path;
li.addEventListener("click", () => void openPath(r.path));
li.addEventListener("mousedown", (e) => {
e.preventDefault();
void openPath(r.path);
});
recentList.appendChild(li);
}
}
// ---- drag and drop reordering ----
let draggingId: string | null = null;
chapterList.addEventListener("dragstart", (e) => {
const li = (e.target as HTMLElement).closest("li.chapter-row") as HTMLElement | null;
if (!li) return;
draggingId = li.dataset.id ?? null;
li.classList.add("dragging");
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", draggingId ?? "");
}
});
chapterList.addEventListener("dragend", (e) => {
const li = (e.target as HTMLElement).closest("li.chapter-row") as HTMLElement | null;
li?.classList.remove("dragging");
draggingId = null;
});
chapterList.addEventListener("dragover", (e) => {
e.preventDefault();
if (!draggingId || !e.dataTransfer) return;
const li = (e.target as HTMLElement).closest("li.chapter-row") as HTMLElement | null;
if (!li) return;
const overId = li.dataset.id;
if (overId === draggingId) return;
chapterList.querySelectorAll(".chapter-row").forEach((n) => n.classList.remove("drop-target"));
li.classList.add("drop-target");
e.dataTransfer.dropEffect = "move";
});
chapterList.addEventListener("dragleave", (e) => {
const related = e.relatedTarget as HTMLElement | null;
if (!related || !(related?.closest(".chapter-row"))) {
chapterList.querySelectorAll(".chapter-row").forEach((n) => n.classList.remove("drop-target"));
}
});
chapterList.addEventListener("drop", (e) => {
e.preventDefault();
if (!draggingId) return;
const li = (e.target as HTMLElement).closest("li.chapter-row") as HTMLElement | null;
if (!li) return;
const targetId = li.dataset.id;
if (targetId === draggingId) return;
const from = ordered.findIndex((c) => c.id === draggingId);
const to = ordered.findIndex((c) => c.id === targetId);
if (from >= 0 && to >= 0) {
ordered = moveInList(ordered, from, to);
meta!.chapterOrder = ordered.map((c) => c.id);
renderChapterList();
// re-select dragged item
selectedId = draggingId;
void persistOrder();
}
chapterList.querySelectorAll(".chapter-row").forEach((n) => n.classList.remove("drop-target"));
});
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
newChapterBtn.addEventListener("click", () => void create());
@ -276,8 +375,9 @@ export function initBook(api: FolioAPI) {
if (currentBook) void api.revealInFolder(currentBook.path);
});
api.onBookOpened((data) => openBook(data));
api.onBookOpened((data) => setCurrentBook(data));
api.onBookClosed(showWelcome);
api.onChaptersChanged(() => void refreshMeta());
api.onNewBookRequested(() => void startNewBook());
api.onOpenBookRequested(() => void startOpenBook());
api.onOpenRecent((p) => void openPath(p));

View file

@ -291,15 +291,32 @@ html, body {
justify-content: space-between;
padding: 8px 10px;
border-radius: var(--radius);
cursor: pointer;
cursor: grab;
font-family: var(--font-serif);
font-size: 0.95rem;
color: var(--color-text);
transition: background 0.12s ease, color 0.12s ease;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.85;
border: 1px solid transparent;
}
.chapter-row.dragging {
opacity: 0.4;
cursor: grabbing;
}
.chapter-row.drop-target {
border-color: var(--color-accent);
background: var(--color-accent);
color: #fff;
}
.chapter-row:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
.chapter-row:hover {
@ -307,7 +324,10 @@ html, body {
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
border: 1px solid var(--color-accent);
}
.chapter-row:hover .row-actions {
display: inline-flex;
}
.chapter-row.active {