diff --git a/src/main/chapters.ts b/src/main/chapters.ts index 08bab3b..dae6226 100644 --- a/src/main/chapters.ts +++ b/src/main/chapters.ts @@ -106,3 +106,11 @@ export function reorderChapters(bookPath: string, newOrder: string[]): void { meta.chapterOrder = newOrder; saveMeta(bookPath, meta); } + +export function moveInOrder(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; +} diff --git a/src/main/index.ts b/src/main/index.ts index ffd0fc0..7103689 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -103,6 +103,10 @@ function refreshMenu() { ); } +function notifyChange() { + getWindow()?.webContents.send("folio:chapters-changed"); +} + async function openBookAt(p: string): Promise { 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 }; diff --git a/src/main/preload.ts b/src/main/preload.ts index 2b4374c..d4b3ece 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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) => diff --git a/src/renderer/book.ts b/src/renderer/book.ts index 19c07a4..15e2d0b 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -17,6 +17,7 @@ export type FolioAPI = { reorderChapters: (newOrder: string[]) => Promise; 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(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 = ` ${ch.title} @@ -124,6 +134,7 @@ export function initBook(api: FolioAPI) { `; 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)); diff --git a/src/renderer/index.css b/src/renderer/index.css index 15c676f..57c0270 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -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 { diff --git a/tests/run-chapters-test.mjs b/tests/run-chapters-test.mjs index 5ea3237..546b2d1 100644 --- a/tests/run-chapters-test.mjs +++ b/tests/run-chapters-test.mjs @@ -38,6 +38,7 @@ const { deleteChapter, duplicateChapter, reorderChapters, + moveInOrder, } = chapters; let failures = 0; @@ -105,6 +106,11 @@ threw = false; try { createChapter(dir, " "); } catch { threw = true; } 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(chapOut, { force: true }); fs.rmSync(dir, { recursive: true, force: true }); diff --git a/tests/selftest-scenario.js b/tests/selftest-scenario.js index 4c6eef9..550fa7f 100644 --- a/tests/selftest-scenario.js +++ b/tests/selftest-scenario.js @@ -31,6 +31,44 @@ const BOOK_DIR = __BOOKDIR_VALUE__; 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; } catch (e) { out.error = String((e && e.message) || e);