Add Undo for book and chapter deletions
- Show an Undo toast after deleting a book from Home; Undo restores the folder from the system trash (XDG .trashinfo, URL-decoded path match). - Chapter delete/cut now snapshots the chapter (title, color, content, position) and offers Undo to restore it in place. - Add reusable showUndoToast/hideUndoToast and #undoToast UI + styles; expose restoreBook/restoreChapter over the preload bridge. - Drop the now-redundant 'cannot be undone' confirm on chapter delete and the inaccurate 'permanently removes' wording on book delete.
This commit is contained in:
parent
5ac7cdb593
commit
aaa491424d
7 changed files with 210 additions and 16 deletions
|
|
@ -155,6 +155,32 @@ export function deleteChapter(bookPath: string, id: string): void {
|
|||
saveMeta(bookPath, meta);
|
||||
}
|
||||
|
||||
// Snapshot of a deleted chapter so it can be restored in place (Undo).
|
||||
export interface ChapterSnapshot {
|
||||
id: string;
|
||||
title: string;
|
||||
color?: string | null;
|
||||
content: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
// Re-create a previously deleted chapter at its original position, restoring
|
||||
// its title, color, and content. The chapter is stored as Markdown so it reads
|
||||
// correctly regardless of the format it originally used.
|
||||
export function restoreChapter(bookPath: string, snap: ChapterSnapshot): ChapterEntry {
|
||||
const meta = loadBook(bookPath);
|
||||
const file = path.posix.join("chapters", chapterFileName(snap.id));
|
||||
fs.mkdirSync(path.join(bookPath, "chapters"), { recursive: true });
|
||||
fs.writeFileSync(path.join(bookPath, file), snap.content, "utf-8");
|
||||
const entry: ChapterEntry = { id: snap.id, title: snap.title, file };
|
||||
if (snap.color) entry.color = snap.color;
|
||||
meta.chapters[snap.id] = entry;
|
||||
const idx = Math.max(0, Math.min(snap.index, meta.chapterOrder.length));
|
||||
meta.chapterOrder.splice(idx, 0, snap.id);
|
||||
saveMeta(bookPath, meta);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function duplicateChapter(
|
||||
bookPath: string,
|
||||
id: string,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
reorderChapters,
|
||||
listChaptersInOrder,
|
||||
setChapterContent,
|
||||
restoreChapter,
|
||||
} from "./chapters.js";
|
||||
import { combineMarkdown, buildZip } from "./export.js";
|
||||
import { buildDocx, buildOdt } from "./office-export.js";
|
||||
|
|
@ -309,6 +310,58 @@ ipcMain.handle("folio:deleteChapter", (_e, id: string) => {
|
|||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:restoreChapter", (_e, snap: unknown) => {
|
||||
try {
|
||||
restoreChapter(requireBook(), snap as never);
|
||||
notifyChange();
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Restore a book the user just trashed (Undo for book deletion). The desktop
|
||||
// trash follows the XDG spec on Linux; we match the original path in the
|
||||
// .trashinfo and move the item back.
|
||||
function restoreBookFromTrash(p: string): { ok?: boolean; error?: string } {
|
||||
try {
|
||||
const home = app.getPath("home");
|
||||
const infoDir = path.join(home, ".local", "share", "Trash", "info");
|
||||
const filesDir = path.join(home, ".local", "share", "Trash", "files");
|
||||
if (!fs.existsSync(infoDir)) return { error: "Trash not available." };
|
||||
for (const infoFile of fs.readdirSync(infoDir)) {
|
||||
if (!infoFile.endsWith(".trashinfo")) continue;
|
||||
const txt = fs.readFileSync(path.join(infoDir, infoFile), "utf-8");
|
||||
const m = /^Path=(.*)$/m.exec(txt);
|
||||
if (!m) continue;
|
||||
let orig = decodeURIComponent(m[1].trim());
|
||||
if (!path.isAbsolute(orig)) orig = path.join(home, orig);
|
||||
if (orig !== p) continue;
|
||||
const base = infoFile.slice(0, -".trashinfo".length);
|
||||
const trashed = path.join(filesDir, base);
|
||||
if (!fs.existsSync(trashed)) return { error: "Trashed book missing." };
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.renameSync(trashed, p);
|
||||
fs.rmSync(path.join(infoDir, infoFile), { force: true });
|
||||
try {
|
||||
if (process.env.FOLIO_SELF_TEST !== "1") {
|
||||
const meta = loadBook(p);
|
||||
addRecent({ path: p, title: meta.title, lastOpened: new Date().toISOString() });
|
||||
}
|
||||
} catch {
|
||||
/* recents best-effort */
|
||||
}
|
||||
refreshMenu();
|
||||
return { ok: true };
|
||||
}
|
||||
return { error: "Book not found in trash." };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("folio:restoreBook", (_e, p: string) => restoreBookFromTrash(p));
|
||||
|
||||
ipcMain.handle("folio:duplicateChapter", (_e, id: string, title?: string) => {
|
||||
try {
|
||||
const entry = duplicateChapter(requireBook(), id, title);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
// book / project
|
||||
newBookNamed: (name: string) => ipcRenderer.invoke("folio:newBookNamed", name),
|
||||
deleteBook: (p: string) => ipcRenderer.invoke("folio:deleteBook", p),
|
||||
restoreBook: (p: string) => ipcRenderer.invoke("folio:restoreBook", p),
|
||||
openBook: () => ipcRenderer.invoke("folio:openBook"),
|
||||
openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p),
|
||||
revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p),
|
||||
|
|
@ -24,6 +25,8 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
ipcRenderer.invoke("folio:setChapterColor", id, color),
|
||||
deleteChapter: (id: string) =>
|
||||
ipcRenderer.invoke("folio:deleteChapter", id),
|
||||
restoreChapter: (snap: unknown) =>
|
||||
ipcRenderer.invoke("folio:restoreChapter", snap),
|
||||
duplicateChapter: (id: string, title?: string) =>
|
||||
ipcRenderer.invoke("folio:duplicateChapter", id, title),
|
||||
reorderChapters: (newOrder: string[]) =>
|
||||
|
|
|
|||
|
|
@ -17,9 +17,18 @@ import {
|
|||
nativeNodeText,
|
||||
} from "./native-assembly.js";
|
||||
|
||||
export interface ChapterSnapshot {
|
||||
id: string;
|
||||
title: string;
|
||||
color?: string | null;
|
||||
content: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export type FolioAPI = {
|
||||
newBookNamed: (name: string) => Promise<any>;
|
||||
deleteBook: (p: string) => Promise<any>;
|
||||
restoreBook: (p: string) => Promise<any>;
|
||||
openBook: () => Promise<any>;
|
||||
openBookPath: (p: string) => Promise<any>;
|
||||
revealInFolder: (p: string) => Promise<boolean>;
|
||||
|
|
@ -33,6 +42,7 @@ export type FolioAPI = {
|
|||
renameChapter: (id: string, title: string) => Promise<any>;
|
||||
setChapterColor: (id: string, color: string | null) => Promise<any>;
|
||||
deleteChapter: (id: string) => Promise<any>;
|
||||
restoreChapter: (snap: ChapterSnapshot) => Promise<any>;
|
||||
duplicateChapter: (id: string, title?: string) => Promise<any>;
|
||||
reorderChapters: (newOrder: string[]) => Promise<any>;
|
||||
saveChapter: (id: string, content: string) => Promise<any>;
|
||||
|
|
@ -937,6 +947,34 @@ export function initBook(api: FolioAPI) {
|
|||
}, 1400);
|
||||
}
|
||||
|
||||
// Toast with an Undo action, used after destructive operations (delete book,
|
||||
// delete/cut chapter). Auto-dismisses; the action runs once if tapped.
|
||||
let undoToastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function hideUndoToast(): void {
|
||||
const el = $("undoToast");
|
||||
if (!el) return;
|
||||
el.classList.remove("show");
|
||||
setTimeout(() => el.classList.add("hidden"), 200);
|
||||
}
|
||||
function showUndoToast(msg: string, onUndo: () => void, timeout = 9000): void {
|
||||
const el = $("undoToast");
|
||||
if (!el) {
|
||||
onUndo();
|
||||
return;
|
||||
}
|
||||
const msgEl = $("undoToastMsg");
|
||||
const btn = $("undoToastBtn") as HTMLButtonElement | null;
|
||||
if (msgEl) msgEl.textContent = msg;
|
||||
if (btn) btn.onclick = () => {
|
||||
hideUndoToast();
|
||||
onUndo();
|
||||
};
|
||||
el.classList.remove("hidden");
|
||||
el.classList.add("show");
|
||||
if (undoToastTimer) clearTimeout(undoToastTimer);
|
||||
undoToastTimer = setTimeout(hideUndoToast, timeout);
|
||||
}
|
||||
|
||||
async function saveCurrentChapter(): Promise<void> {
|
||||
if (!selectedId) return;
|
||||
const res = await api.saveChapterDoc(selectedId, getEditorDoc(editor));
|
||||
|
|
@ -1285,14 +1323,31 @@ export function initBook(api: FolioAPI) {
|
|||
async function del(id: string) {
|
||||
if (!meta || !meta.chapters[id]) return;
|
||||
const entry = meta.chapters[id];
|
||||
const confirmed = await confirmMessage(`Delete "${entry.title}"?`, "This cannot be undone.");
|
||||
if (!confirmed) return;
|
||||
const index = meta.chapterOrder.indexOf(id);
|
||||
const content = (await api.getChapterContent(id)) as string;
|
||||
const res = await api.deleteChapter(id);
|
||||
if (res && "error" in res) await alertMessage("Could not delete chapter", (res as { error: string }).error);
|
||||
else {
|
||||
if (selectedId === id) { selectedId = null; showPlaceholder(); }
|
||||
await refreshMeta();
|
||||
if (res && "error" in res) {
|
||||
await alertMessage("Could not delete chapter", (res as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
const wasSelected = selectedId === id;
|
||||
if (wasSelected) { selectedId = null; showPlaceholder(); }
|
||||
await refreshMeta();
|
||||
showUndoToast(`Deleted "${entry.title}"`, async () => {
|
||||
const r = await api.restoreChapter({
|
||||
id,
|
||||
title: entry.title,
|
||||
color: entry.color ?? null,
|
||||
content,
|
||||
index,
|
||||
});
|
||||
if (r && "error" in r) {
|
||||
await alertMessage("Could not restore chapter", (r as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
await refreshMeta();
|
||||
if (wasSelected) await selectChapter(id);
|
||||
});
|
||||
}
|
||||
|
||||
async function duplicate(id: string) {
|
||||
|
|
@ -1394,17 +1449,20 @@ export function initBook(api: FolioAPI) {
|
|||
del.textContent = "×";
|
||||
del.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const ok = await confirmMessage(
|
||||
"Delete book",
|
||||
`Delete "${r.title || r.path}"? This permanently removes the book folder.`
|
||||
);
|
||||
if (!ok) return;
|
||||
const res = await api.deleteBook(r.path);
|
||||
if (res && "error" in res) {
|
||||
await alertMessage("Could not delete", (res as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
await renderRecents();
|
||||
showUndoToast(`Deleted "${r.title || r.path}"`, async () => {
|
||||
const rr = await api.restoreBook(r.path);
|
||||
if (rr && "error" in rr) {
|
||||
await alertMessage("Could not restore", (rr as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
await renderRecents();
|
||||
});
|
||||
});
|
||||
li.appendChild(label);
|
||||
li.appendChild(del);
|
||||
|
|
|
|||
|
|
@ -1072,6 +1072,26 @@ body.dragging-active * {
|
|||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.undo-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.undo-toast .toast-action {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
padding: 5px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.undo-toast .toast-action:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ---- toolbar dropdowns ---- */
|
||||
.toolbar-dropdown {
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -273,6 +273,10 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="toast hidden" id="toast"></div>
|
||||
<div class="toast undo-toast hidden" id="undoToast" role="status" aria-live="polite">
|
||||
<span id="undoToastMsg"></span>
|
||||
<button type="button" class="toast-action" id="undoToastBtn">Undo</button>
|
||||
</div>
|
||||
<div class="modal-backdrop hidden" id="searchModal">
|
||||
<div class="modal search-modal" role="dialog" aria-modal="true" aria-label="Search all chapters">
|
||||
<h3 class="modal-title">Search</h3>
|
||||
|
|
|
|||
|
|
@ -170,7 +170,33 @@
|
|||
}
|
||||
step("exports", ex);
|
||||
|
||||
// New book auto-creates a folder inside Documents; then delete it.
|
||||
// Chapter delete + undo (snapshot restore).
|
||||
try {
|
||||
const before = (await api.getChapterList()).length;
|
||||
const cc = await api.getChapterContent("ch-one");
|
||||
const delc = await api.deleteChapter("ch-one");
|
||||
const afterDel = (await api.getChapterList()).length;
|
||||
const restc = await api.restoreChapter({
|
||||
id: "ch-one",
|
||||
title: "Book_The_Lab",
|
||||
color: null,
|
||||
content: cc,
|
||||
index: 0,
|
||||
});
|
||||
const afterRest = (await api.getChapterList()).length;
|
||||
const cc2 = await api.getChapterContent("ch-one");
|
||||
step("chapterUndo", {
|
||||
before,
|
||||
afterDel,
|
||||
afterRest,
|
||||
restored: cc2 === cc,
|
||||
ok: !!(delc && delc.ok) && !!(restc && restc.ok),
|
||||
});
|
||||
} catch (e) {
|
||||
step("chapterUndoErr", String(e));
|
||||
}
|
||||
|
||||
// New book auto-creates a folder inside Documents; delete then undo.
|
||||
const nb = await api.newBookNamed("Folio Selftest Temp");
|
||||
step("newBookNamed", {
|
||||
ok: !!(nb && nb.path && nb.title),
|
||||
|
|
@ -180,6 +206,10 @@
|
|||
if (nb && nb.path) {
|
||||
const del = await api.deleteBook(nb.path);
|
||||
step("deleteBook", del && del.ok ? "ok" : del && del.error);
|
||||
if (del && del.ok) {
|
||||
const rest = await api.restoreBook(nb.path);
|
||||
step("restoreBook", rest && rest.ok ? "ok" : rest && rest.error);
|
||||
}
|
||||
}
|
||||
|
||||
out.ok = true;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue