Stage 4: drag-and-drop + keyboard chapter reordering (moveInList), chapters-changed broadcast, DnD/keyboard e2e
This commit is contained in:
parent
d3ab261346
commit
4b8d78458c
7 changed files with 217 additions and 34 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue