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