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; meta.chapterOrder = newOrder;
saveMeta(bookPath, meta); 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> { async function openBookAt(p: string): Promise<BookResult | ErrResult> {
try { try {
const meta = loadBook(p); const meta = loadBook(p);
@ -189,6 +193,7 @@ ipcMain.handle("folio:getChapterContent", (_e, id: string) => {
ipcMain.handle("folio:createChapter", (_e, title: string) => { ipcMain.handle("folio:createChapter", (_e, title: string) => {
try { try {
const entry = createChapter(requireBook(), title); const entry = createChapter(requireBook(), title);
notifyChange();
return { id: entry.id, title: entry.title, file: entry.file }; return { id: entry.id, title: entry.title, file: entry.file };
} catch (e) { } catch (e) {
return { error: (e as Error).message }; 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) => { ipcMain.handle("folio:renameChapter", (_e, id: string, title: string) => {
try { try {
renameChapter(requireBook(), id, title); renameChapter(requireBook(), id, title);
notifyChange();
return { ok: true }; return { ok: true };
} catch (e) { } catch (e) {
return { error: (e as Error).message }; 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) => { ipcMain.handle("folio:deleteChapter", (_e, id: string) => {
try { try {
deleteChapter(requireBook(), id); deleteChapter(requireBook(), id);
notifyChange();
return { ok: true }; return { ok: true };
} catch (e) { } catch (e) {
return { error: (e as Error).message }; 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) => { ipcMain.handle("folio:duplicateChapter", (_e, id: string, title?: string) => {
try { try {
const entry = duplicateChapter(requireBook(), id, title); const entry = duplicateChapter(requireBook(), id, title);
notifyChange();
return { id: entry.id, title: entry.title }; return { id: entry.id, title: entry.title };
} catch (e) { } catch (e) {
return { error: (e as Error).message }; 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[]) => { ipcMain.handle("folio:reorderChapters", (_e, newOrder: string[]) => {
try { try {
reorderChapters(requireBook(), newOrder); reorderChapters(requireBook(), newOrder);
notifyChange();
return { ok: true }; return { ok: true };
} catch (e) { } catch (e) {
return { error: (e as Error).message }; return { error: (e as Error).message };

View file

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

View file

@ -17,6 +17,7 @@ export type FolioAPI = {
reorderChapters: (newOrder: string[]) => Promise<any>; reorderChapters: (newOrder: string[]) => Promise<any>;
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void; onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
onBookClosed: (cb: () => void) => void; onBookClosed: (cb: () => void) => void;
onChaptersChanged: (cb: () => void) => void;
onNewBookRequested: (cb: () => void) => void; onNewBookRequested: (cb: () => void) => void;
onOpenBookRequested: (cb: () => void) => void; onOpenBookRequested: (cb: () => void) => void;
onOpenRecent: (cb: (p: string) => void) => void; onOpenRecent: (cb: (p: string) => void) => void;
@ -37,9 +38,18 @@ interface Recent {
lastOpened: string; 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) { export function initBook(api: FolioAPI) {
let currentBook: { path: string; title: string } | null = null; let currentBook: { path: string; title: string } | null = null;
let meta: BookMeta | null = null; let meta: BookMeta | null = null;
let ordered: ChapterEntry[] = [];
let selectedId: string | null = null; let selectedId: string | null = null;
const chapterList = $("chapterList") as HTMLUListElement; const chapterList = $("chapterList") as HTMLUListElement;
@ -62,9 +72,9 @@ export function initBook(api: FolioAPI) {
projectBar.classList.add("hidden"); projectBar.classList.add("hidden");
currentBook = null; currentBook = null;
meta = null; meta = null;
ordered = [];
selectedId = null; selectedId = null;
renderChapterList(); renderChapterList();
void renderRecents();
} }
function showPlaceholder() { function showPlaceholder() {
@ -72,7 +82,7 @@ export function initBook(api: FolioAPI) {
editorPlaceholder.classList.remove("hidden"); editorPlaceholder.classList.remove("hidden");
} }
function openBook(data: { path: string; title: string }) { function setCurrentBook(data: { path: string; title: string }) {
currentBook = data; currentBook = data;
bookTitleEl.textContent = data.title; bookTitleEl.textContent = data.title;
bookPathEl.textContent = data.path; bookPathEl.textContent = data.path;
@ -90,16 +100,18 @@ export function initBook(api: FolioAPI) {
meta = null; meta = null;
} else { } else {
meta = res as BookMeta; meta = res as BookMeta;
ordered = meta.chapterOrder
.map((id) => meta!.chapters[id])
.filter(Boolean) as ChapterEntry[];
} }
renderChapterList(); renderChapterList();
showPlaceholder(); if (ordered.length) showPlaceholder();
else showPlaceholder();
} }
async function refreshMeta() { async function refreshMeta() {
await loadMeta(); await loadMeta();
if (meta && meta.chapters[selectedId ?? ""]) { if (meta && ordered.length === 0) {
// keep selection if still valid
} else {
selectedId = null; selectedId = null;
showPlaceholder(); showPlaceholder();
} }
@ -107,14 +119,12 @@ export function initBook(api: FolioAPI) {
function renderChapterList() { function renderChapterList() {
chapterList.innerHTML = ""; chapterList.innerHTML = "";
if (!meta) return;
const ordered = meta.chapterOrder
.map((id) => meta!.chapters[id])
.filter(Boolean) as ChapterEntry[];
for (const ch of ordered) { for (const ch of ordered) {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "chapter-row" + (selectedId === ch.id ? " active" : ""); li.className = "chapter-row" + (selectedId === ch.id ? " active" : "");
li.dataset.id = ch.id; li.dataset.id = ch.id;
li.draggable = true;
li.tabIndex = -1;
li.innerHTML = ` li.innerHTML = `
<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">
@ -124,6 +134,7 @@ export function initBook(api: FolioAPI) {
</span>`; </span>`;
li.addEventListener("click", (e) => { li.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
li.focus();
void selectChapter(ch.id); void selectChapter(ch.id);
}); });
li.querySelector('[data-act="rename"]')?.addEventListener("click", (e) => { li.querySelector('[data-act="rename"]')?.addEventListener("click", (e) => {
@ -138,6 +149,11 @@ export function initBook(api: FolioAPI) {
e.stopPropagation(); e.stopPropagation();
void del(ch.id); 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); chapterList.appendChild(li);
} }
} }
@ -171,11 +187,10 @@ export function initBook(api: FolioAPI) {
const title = prompt("Chapter title", `Chapter ${count}`); const title = prompt("Chapter title", `Chapter ${count}`);
if (!title || !title.trim()) return; if (!title || !title.trim()) return;
const res = await api.createChapter(title.trim()); const res = await api.createChapter(title.trim());
if (res && "error" in res) { if (res && "error" in res) alert((res as { error: string }).error);
alert((res as { error: string }).error); else {
} else if (res && "id" in res) {
await refreshMeta(); 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); const res = await api.deleteChapter(id);
if (res && "error" in res) alert((res as { error: string }).error); if (res && "error" in res) alert((res as { error: string }).error);
else { else {
if (selectedId === id) { if (selectedId === id) { selectedId = null; showPlaceholder(); }
selectedId = null;
showPlaceholder();
}
await refreshMeta(); 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 (!res) return;
if ("canceled" in res) return; if ("canceled" in res) return;
if ("error" in res) { if ("error" in res) { alert((res as { error: string }).error); return; }
alert((res as { error: string }).error); if ("path" in res) setCurrentBook({ path: res.path, title: res.title });
return;
}
if ("path" in res) openBook({ path: res.path, title: res.title });
} }
async function startNewBook() { async function startNewBook() {
const res = await api.newBook(); const res = await api.newBook();
await handleResult(res); handleResult(res);
await renderRecents(); await renderRecents();
} }
async function startOpenBook() { async function startOpenBook() {
const res = await api.openBook(); const res = await api.openBook();
await handleResult(res); handleResult(res);
await renderRecents(); await renderRecents();
} }
async function openPath(p: string) { async function openPath(p: string) {
const res = await api.openBookPath(p); const res = await api.openBookPath(p);
await handleResult(res); handleResult(res);
await renderRecents(); await renderRecents();
} }
async function renderRecents() { async function renderRecents() {
const recents = (await api.getRecents()) as Recent[]; const recents = await api.getRecents();
recentList.innerHTML = ""; recentList.innerHTML = "";
if (!recents.length) { if (!recents.length) {
const li = document.createElement("li"); const li = document.createElement("li");
@ -260,15 +297,77 @@ export function initBook(api: FolioAPI) {
recentList.appendChild(li); recentList.appendChild(li);
return; return;
} }
for (const r of recents) { for (const r of recents as Recent[]) {
const li = document.createElement("li"); const li = document.createElement("li");
li.textContent = r.title || r.path; li.textContent = r.title || r.path;
li.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); 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()); $("welcomeNewBook").addEventListener("click", () => void startNewBook());
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook()); $("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
newChapterBtn.addEventListener("click", () => void create()); newChapterBtn.addEventListener("click", () => void create());
@ -276,8 +375,9 @@ export function initBook(api: FolioAPI) {
if (currentBook) void api.revealInFolder(currentBook.path); if (currentBook) void api.revealInFolder(currentBook.path);
}); });
api.onBookOpened((data) => openBook(data)); api.onBookOpened((data) => setCurrentBook(data));
api.onBookClosed(showWelcome); api.onBookClosed(showWelcome);
api.onChaptersChanged(() => void refreshMeta());
api.onNewBookRequested(() => void startNewBook()); api.onNewBookRequested(() => void startNewBook());
api.onOpenBookRequested(() => void startOpenBook()); api.onOpenBookRequested(() => void startOpenBook());
api.onOpenRecent((p) => void openPath(p)); api.onOpenRecent((p) => void openPath(p));

View file

@ -291,15 +291,32 @@ html, body {
justify-content: space-between; justify-content: space-between;
padding: 8px 10px; padding: 8px 10px;
border-radius: var(--radius); border-radius: var(--radius);
cursor: pointer; cursor: grab;
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: 0.95rem; font-size: 0.95rem;
color: var(--color-text); 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; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
opacity: 0.85; 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 { .chapter-row:hover {
@ -307,7 +324,10 @@ html, body {
background: var(--color-accent); background: var(--color-accent);
color: #fff; color: #fff;
border-color: var(--color-accent); border-color: var(--color-accent);
border: 1px solid var(--color-accent); }
.chapter-row:hover .row-actions {
display: inline-flex;
} }
.chapter-row.active { .chapter-row.active {

View file

@ -38,6 +38,7 @@ const {
deleteChapter, deleteChapter,
duplicateChapter, duplicateChapter,
reorderChapters, reorderChapters,
moveInOrder,
} = chapters; } = chapters;
let failures = 0; let failures = 0;
@ -105,6 +106,11 @@ threw = false;
try { createChapter(dir, " "); } catch { threw = true; } try { createChapter(dir, " "); } catch { threw = true; }
ok(threw, "createChapter rejects empty title"); ok(threw, "createChapter rejects empty title");
// moveInOrder (pure reorder math used by drag & keyboard reorder)
ok(JSON.stringify(moveInOrder(["a", "b", "c", "d"], 0, 2)) === JSON.stringify(["b", "c", "a", "d"]), "move item to middle");
ok(JSON.stringify(moveInOrder(["a", "b", "c", "d"], 3, 0)) === JSON.stringify(["d", "a", "b", "c"]), "move last to top");
ok(JSON.stringify(moveInOrder(["a", "b", "c", "d"], 1, 1)) === JSON.stringify(["a", "b", "c", "d"]), "no-op on same index");
fs.rmSync(projOut, { force: true }); fs.rmSync(projOut, { force: true });
fs.rmSync(chapOut, { force: true }); fs.rmSync(chapOut, { force: true });
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });

View file

@ -31,6 +31,44 @@ const BOOK_DIR = __BOOKDIR_VALUE__;
out.dupContent = await window.folio.getChapterContent(did); out.dupContent = await window.folio.getChapterContent(did);
// --- drag & drop reorder test ---
const a = (await window.folio.createChapter("Alpha")).id;
await new Promise((r) => setTimeout(r, 80));
const b = (await window.folio.createChapter("Bravo")).id;
await new Promise((r) => setTimeout(r, 80));
const c = (await window.folio.createChapter("Charlie")).id;
await new Promise((r) => setTimeout(r, 200));
const orderBefore = (await window.folio.getBookMeta()).chapterOrder;
out.orderBeforeDnD = orderBefore;
const rows = document.getElementById("chapterList").querySelectorAll(".chapter-row");
out.dndRowCount = rows.length;
const draggedId = rows[0].dataset.id;
const targetId = rows[2].dataset.id;
const dt = new DataTransfer();
rows[0].dispatchEvent(new DragEvent("dragstart", { dataTransfer: dt, bubbles: true }));
rows[2].dispatchEvent(new DragEvent("dragover", { dataTransfer: dt, bubbles: true }));
rows[2].dispatchEvent(new DragEvent("drop", { dataTransfer: dt, bubbles: true }));
rows[0].dispatchEvent(new DragEvent("dragend", { dataTransfer: dt, bubbles: true }));
await new Promise((r) => setTimeout(r, 250));
const orderAfter = (await window.folio.getBookMeta()).chapterOrder;
out.orderAfterDnD = orderAfter;
out.dndMoved =
orderAfter[0] === orderBefore[1] &&
orderAfter[2] === orderBefore[0] &&
orderAfter[3] === orderBefore[3];
// keyboard reorder: select first visible row, Alt+ArrowDown moves it down
const fresh1 = document.getElementById("chapterList").querySelectorAll(".chapter-row");
fresh1[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await new Promise((r) => setTimeout(r, 120));
const fresh2 = document.getElementById("chapterList").querySelectorAll(".chapter-row");
fresh2[0].focus();
fresh2[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", altKey: true, bubbles: true }));
await new Promise((r) => setTimeout(r, 250));
const orderKb = (await window.folio.getBookMeta()).chapterOrder;
out.orderAfterKb = orderKb;
out.kbMoved = orderKb[0] === orderAfter[1] && orderKb[1] === orderAfter[0];
out.all = true; out.all = true;
} catch (e) { } catch (e) {
out.error = String((e && e.message) || e); out.error = String((e && e.message) || e);