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);
|
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(
|
export function duplicateChapter(
|
||||||
bookPath: string,
|
bookPath: string,
|
||||||
id: string,
|
id: string,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
reorderChapters,
|
reorderChapters,
|
||||||
listChaptersInOrder,
|
listChaptersInOrder,
|
||||||
setChapterContent,
|
setChapterContent,
|
||||||
|
restoreChapter,
|
||||||
} from "./chapters.js";
|
} from "./chapters.js";
|
||||||
import { combineMarkdown, buildZip } from "./export.js";
|
import { combineMarkdown, buildZip } from "./export.js";
|
||||||
import { buildDocx, buildOdt } from "./office-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) => {
|
ipcMain.handle("folio:duplicateChapter", (_e, id: string, title?: string) => {
|
||||||
try {
|
try {
|
||||||
const entry = duplicateChapter(requireBook(), id, title);
|
const entry = duplicateChapter(requireBook(), id, title);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ contextBridge.exposeInMainWorld("folio", {
|
||||||
// book / project
|
// book / project
|
||||||
newBookNamed: (name: string) => ipcRenderer.invoke("folio:newBookNamed", name),
|
newBookNamed: (name: string) => ipcRenderer.invoke("folio:newBookNamed", name),
|
||||||
deleteBook: (p: string) => ipcRenderer.invoke("folio:deleteBook", p),
|
deleteBook: (p: string) => ipcRenderer.invoke("folio:deleteBook", p),
|
||||||
|
restoreBook: (p: string) => ipcRenderer.invoke("folio:restoreBook", p),
|
||||||
openBook: () => ipcRenderer.invoke("folio:openBook"),
|
openBook: () => ipcRenderer.invoke("folio:openBook"),
|
||||||
openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p),
|
openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p),
|
||||||
revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p),
|
revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p),
|
||||||
|
|
@ -24,6 +25,8 @@ contextBridge.exposeInMainWorld("folio", {
|
||||||
ipcRenderer.invoke("folio:setChapterColor", id, color),
|
ipcRenderer.invoke("folio:setChapterColor", id, color),
|
||||||
deleteChapter: (id: string) =>
|
deleteChapter: (id: string) =>
|
||||||
ipcRenderer.invoke("folio:deleteChapter", id),
|
ipcRenderer.invoke("folio:deleteChapter", id),
|
||||||
|
restoreChapter: (snap: unknown) =>
|
||||||
|
ipcRenderer.invoke("folio:restoreChapter", snap),
|
||||||
duplicateChapter: (id: string, title?: string) =>
|
duplicateChapter: (id: string, title?: string) =>
|
||||||
ipcRenderer.invoke("folio:duplicateChapter", id, title),
|
ipcRenderer.invoke("folio:duplicateChapter", id, title),
|
||||||
reorderChapters: (newOrder: string[]) =>
|
reorderChapters: (newOrder: string[]) =>
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,18 @@ import {
|
||||||
nativeNodeText,
|
nativeNodeText,
|
||||||
} from "./native-assembly.js";
|
} from "./native-assembly.js";
|
||||||
|
|
||||||
|
export interface ChapterSnapshot {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
color?: string | null;
|
||||||
|
content: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type FolioAPI = {
|
export type FolioAPI = {
|
||||||
newBookNamed: (name: string) => Promise<any>;
|
newBookNamed: (name: string) => Promise<any>;
|
||||||
deleteBook: (p: string) => Promise<any>;
|
deleteBook: (p: string) => Promise<any>;
|
||||||
|
restoreBook: (p: string) => Promise<any>;
|
||||||
openBook: () => Promise<any>;
|
openBook: () => Promise<any>;
|
||||||
openBookPath: (p: string) => Promise<any>;
|
openBookPath: (p: string) => Promise<any>;
|
||||||
revealInFolder: (p: string) => Promise<boolean>;
|
revealInFolder: (p: string) => Promise<boolean>;
|
||||||
|
|
@ -33,6 +42,7 @@ export type FolioAPI = {
|
||||||
renameChapter: (id: string, title: string) => Promise<any>;
|
renameChapter: (id: string, title: string) => Promise<any>;
|
||||||
setChapterColor: (id: string, color: string | null) => Promise<any>;
|
setChapterColor: (id: string, color: string | null) => Promise<any>;
|
||||||
deleteChapter: (id: string) => Promise<any>;
|
deleteChapter: (id: string) => Promise<any>;
|
||||||
|
restoreChapter: (snap: ChapterSnapshot) => Promise<any>;
|
||||||
duplicateChapter: (id: string, title?: string) => Promise<any>;
|
duplicateChapter: (id: string, title?: string) => Promise<any>;
|
||||||
reorderChapters: (newOrder: string[]) => Promise<any>;
|
reorderChapters: (newOrder: string[]) => Promise<any>;
|
||||||
saveChapter: (id: string, content: string) => Promise<any>;
|
saveChapter: (id: string, content: string) => Promise<any>;
|
||||||
|
|
@ -937,6 +947,34 @@ export function initBook(api: FolioAPI) {
|
||||||
}, 1400);
|
}, 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> {
|
async function saveCurrentChapter(): Promise<void> {
|
||||||
if (!selectedId) return;
|
if (!selectedId) return;
|
||||||
const res = await api.saveChapterDoc(selectedId, getEditorDoc(editor));
|
const res = await api.saveChapterDoc(selectedId, getEditorDoc(editor));
|
||||||
|
|
@ -1285,14 +1323,31 @@ export function initBook(api: FolioAPI) {
|
||||||
async function del(id: string) {
|
async function del(id: string) {
|
||||||
if (!meta || !meta.chapters[id]) return;
|
if (!meta || !meta.chapters[id]) return;
|
||||||
const entry = meta.chapters[id];
|
const entry = meta.chapters[id];
|
||||||
const confirmed = await confirmMessage(`Delete "${entry.title}"?`, "This cannot be undone.");
|
const index = meta.chapterOrder.indexOf(id);
|
||||||
if (!confirmed) return;
|
const content = (await api.getChapterContent(id)) as string;
|
||||||
const res = await api.deleteChapter(id);
|
const res = await api.deleteChapter(id);
|
||||||
if (res && "error" in res) await alertMessage("Could not delete chapter", (res as { error: string }).error);
|
if (res && "error" in res) {
|
||||||
else {
|
await alertMessage("Could not delete chapter", (res as { error: string }).error);
|
||||||
if (selectedId === id) { selectedId = null; showPlaceholder(); }
|
return;
|
||||||
await refreshMeta();
|
|
||||||
}
|
}
|
||||||
|
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) {
|
async function duplicate(id: string) {
|
||||||
|
|
@ -1394,17 +1449,20 @@ export function initBook(api: FolioAPI) {
|
||||||
del.textContent = "×";
|
del.textContent = "×";
|
||||||
del.addEventListener("click", async (e) => {
|
del.addEventListener("click", async (e) => {
|
||||||
e.stopPropagation();
|
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);
|
const res = await api.deleteBook(r.path);
|
||||||
if (res && "error" in res) {
|
if (res && "error" in res) {
|
||||||
await alertMessage("Could not delete", (res as { error: string }).error);
|
await alertMessage("Could not delete", (res as { error: string }).error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await renderRecents();
|
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(label);
|
||||||
li.appendChild(del);
|
li.appendChild(del);
|
||||||
|
|
|
||||||
|
|
@ -1067,10 +1067,30 @@ body.dragging-active * {
|
||||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
.toast.show {
|
.toast.show {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(-50%) translateY(0);
|
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 dropdowns ---- */
|
||||||
.toolbar-dropdown {
|
.toolbar-dropdown {
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toast hidden" id="toast"></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-backdrop hidden" id="searchModal">
|
||||||
<div class="modal search-modal" role="dialog" aria-modal="true" aria-label="Search all chapters">
|
<div class="modal search-modal" role="dialog" aria-modal="true" aria-label="Search all chapters">
|
||||||
<h3 class="modal-title">Search</h3>
|
<h3 class="modal-title">Search</h3>
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,33 @@
|
||||||
}
|
}
|
||||||
step("exports", ex);
|
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");
|
const nb = await api.newBookNamed("Folio Selftest Temp");
|
||||||
step("newBookNamed", {
|
step("newBookNamed", {
|
||||||
ok: !!(nb && nb.path && nb.title),
|
ok: !!(nb && nb.path && nb.title),
|
||||||
|
|
@ -180,6 +206,10 @@
|
||||||
if (nb && nb.path) {
|
if (nb && nb.path) {
|
||||||
const del = await api.deleteBook(nb.path);
|
const del = await api.deleteBook(nb.path);
|
||||||
step("deleteBook", del && del.ok ? "ok" : del && del.error);
|
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;
|
out.ok = true;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue