WIP: save Folio changes before OpenCode restart
This commit is contained in:
parent
11dd692ec4
commit
c7323af38f
8 changed files with 194 additions and 8 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron";
|
||||
import type { OpenDialogOptions, SaveDialogOptions } from "electron";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
|
@ -68,7 +68,8 @@ function createWindow() {
|
|||
const scenarioPath = path.join(__dirname, "..", "tests", "selftest-scenario.js");
|
||||
const scenario = fs
|
||||
.readFileSync(scenarioPath, "utf-8")
|
||||
.replace(/__BOOKDIR_VALUE__/, JSON.stringify(bookDir));
|
||||
.replace(/__BOOKDIR_VALUE__/, JSON.stringify(bookDir))
|
||||
.replace(/__BOOKDIR2_VALUE__/, JSON.stringify(process.env.FOLIO_TEST_BOOK2 || ""));
|
||||
mainWindow?.webContents
|
||||
.executeJavaScript(scenario, false)
|
||||
.then((summary) => {
|
||||
|
|
@ -124,7 +125,12 @@ function forwardEditShortcuts(win: BrowserWindow) {
|
|||
const mod = input.control || input.meta;
|
||||
if (!mod) return;
|
||||
const key = input.key.toLowerCase();
|
||||
if (key === "z" && !input.shift) {
|
||||
if (key === "a" && !input.shift && !input.alt) {
|
||||
// Route Ctrl+A to the renderer so a read-only CodeMirror view (Full Book)
|
||||
// can select its whole document instead of the native page-wide selectAll.
|
||||
event.preventDefault();
|
||||
win.webContents.send("folio:selectAll-requested");
|
||||
} else if (key === "z" && !input.shift) {
|
||||
event.preventDefault();
|
||||
win.webContents.send("folio:undo-requested");
|
||||
} else if ((key === "z" && input.shift) || (key === "y" && !input.shift)) {
|
||||
|
|
@ -323,6 +329,15 @@ ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => {
|
|||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:copyText", (_e, text: string) => {
|
||||
clipboard.writeText(typeof text === "string" ? text : "");
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:getClipboardText", () => {
|
||||
return clipboard.readText();
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:importAttachment", async () => {
|
||||
const bp = requireBook();
|
||||
const { canceled, filePaths } = await showOpenDialog({
|
||||
|
|
|
|||
|
|
@ -81,7 +81,11 @@ export function buildMenu(
|
|||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ type: "separator" },
|
||||
{ role: "selectAll" },
|
||||
{
|
||||
label: "Select All",
|
||||
accelerator: "CmdOrCtrl+A",
|
||||
click: () => getWindow()?.webContents.send("folio:selectAll-requested"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"),
|
||||
exportZip: () => ipcRenderer.invoke("folio:exportZip"),
|
||||
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"),
|
||||
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
|
||||
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
|
||||
|
||||
// events
|
||||
onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
|
||||
|
|
@ -52,4 +54,6 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
ipcRenderer.on("folio:undo-requested", () => cb()),
|
||||
onRedoRequested: (cb: () => void) =>
|
||||
ipcRenderer.on("folio:redo-requested", () => cb()),
|
||||
onSelectAllRequested: (cb: () => void) =>
|
||||
ipcRenderer.on("folio:selectAll-requested", () => cb()),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { BookMeta } from "../main/project.js";
|
|||
import { alertMessage, confirmMessage, promptInput } from "./dialog.js";
|
||||
import { createMdEditor, editorText, setEditorText } from "./md-editor.js";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { redo, undo, indentLess, indentMore } from "@codemirror/commands";
|
||||
import { redo, selectAll, undo, indentLess, indentMore } from "@codemirror/commands";
|
||||
import {
|
||||
applyFormatPainter,
|
||||
captureFormat,
|
||||
|
|
@ -54,6 +54,8 @@ export type FolioAPI = {
|
|||
exportMarkdown: () => Promise<any>;
|
||||
exportZip: () => Promise<any>;
|
||||
importAttachment: () => Promise<any>;
|
||||
copyText: (text: string) => Promise<any>;
|
||||
getClipboardText: () => Promise<string>;
|
||||
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
|
||||
onBookClosed: (cb: () => void) => void;
|
||||
onChaptersChanged: (cb: () => void) => void;
|
||||
|
|
@ -65,6 +67,7 @@ export type FolioAPI = {
|
|||
onExportZipRequested: (cb: () => void) => void;
|
||||
onUndoRequested: (cb: () => void) => void;
|
||||
onRedoRequested: (cb: () => void) => void;
|
||||
onSelectAllRequested: (cb: () => void) => void;
|
||||
};
|
||||
|
||||
const $ = (id: string) => document.getElementById(id) as HTMLElement;
|
||||
|
|
@ -106,6 +109,18 @@ export function initBook(api: FolioAPI) {
|
|||
const exportZipBtn = $("exportZipBtn") as HTMLButtonElement;
|
||||
const addChapterBtn = $("addChapterBtn") as HTMLButtonElement;
|
||||
const projectBar = $("projectBar");
|
||||
const fullBookNav = $("fullBookNav");
|
||||
const fullBookBtn = $("fullBookBtn") as HTMLButtonElement;
|
||||
const fullBookView = $("fullBookView");
|
||||
const fullBookHost = $("fullBookHost");
|
||||
const fullBookStats = $("fullBookStats");
|
||||
const copyAllBtn = $("copyAllBtn") as HTMLButtonElement;
|
||||
const fullBookStatus = $("fullBookStatus");
|
||||
|
||||
const fullBookEditor: EditorView = createMdEditor(fullBookHost, {
|
||||
onChange: () => undefined,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const editorView: EditorView = createMdEditor(editorHost, {
|
||||
onChange: (text) => handleEditorChange(text),
|
||||
|
|
@ -325,6 +340,7 @@ export function initBook(api: FolioAPI) {
|
|||
function setChapterButtons(enabled: boolean) {
|
||||
addChapterBtn.disabled = !enabled;
|
||||
newChapterBtn.disabled = !enabled;
|
||||
fullBookNav.classList.toggle("hidden", !enabled);
|
||||
}
|
||||
let dirty = false;
|
||||
let loadedContent = "";
|
||||
|
|
@ -338,6 +354,7 @@ export function initBook(api: FolioAPI) {
|
|||
function showWelcome() {
|
||||
welcome.classList.remove("hidden");
|
||||
chapterView.classList.add("hidden");
|
||||
fullBookView.classList.add("hidden");
|
||||
editorPlaceholder.classList.add("hidden");
|
||||
projectBar.classList.add("hidden");
|
||||
currentBook = null;
|
||||
|
|
@ -345,11 +362,15 @@ export function initBook(api: FolioAPI) {
|
|||
ordered = [];
|
||||
selectedId = null;
|
||||
setChapterButtons(false);
|
||||
fullBookBtn.classList.remove("active");
|
||||
setEditorText(fullBookEditor, "");
|
||||
renderChapterList();
|
||||
}
|
||||
|
||||
function showPlaceholder() {
|
||||
chapterView.classList.add("hidden");
|
||||
fullBookView.classList.add("hidden");
|
||||
fullBookBtn.classList.remove("active");
|
||||
editorPlaceholder.classList.remove("hidden");
|
||||
}
|
||||
|
||||
|
|
@ -377,8 +398,10 @@ export function initBook(api: FolioAPI) {
|
|||
}
|
||||
setChapterButtons(!!meta);
|
||||
renderChapterList();
|
||||
if (ordered.length) showPlaceholder();
|
||||
else showPlaceholder();
|
||||
if (fullBookView.classList.contains("hidden")) {
|
||||
if (ordered.length) showPlaceholder();
|
||||
else showPlaceholder();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshMeta() {
|
||||
|
|
@ -387,6 +410,7 @@ export function initBook(api: FolioAPI) {
|
|||
selectedId = null;
|
||||
showPlaceholder();
|
||||
}
|
||||
await refreshFullBook();
|
||||
}
|
||||
|
||||
function renderChapterList() {
|
||||
|
|
@ -456,6 +480,8 @@ export function initBook(api: FolioAPI) {
|
|||
editorView.dispatch({ selection: { anchor: text.length } });
|
||||
editorView.focus();
|
||||
clearTimers();
|
||||
fullBookBtn.classList.remove("active");
|
||||
fullBookView.classList.add("hidden");
|
||||
chapterView.classList.remove("hidden");
|
||||
editorPlaceholder.classList.add("hidden");
|
||||
} catch {
|
||||
|
|
@ -463,6 +489,69 @@ export function initBook(api: FolioAPI) {
|
|||
}
|
||||
}
|
||||
|
||||
// ---- full book view ----
|
||||
|
||||
function chapterHasOwnHeading(content: string, title: string): boolean {
|
||||
const t = title.trim().toLowerCase();
|
||||
if (!t) return true;
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const m = line.match(/^#{1,6}\s+(.+?)\s*$/);
|
||||
if (m && m[1].trim().toLowerCase() === t) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function assembleFullBook(): Promise<string> {
|
||||
const parts: string[] = [];
|
||||
for (const ch of ordered) {
|
||||
const res = await api.getChapterContent(ch.id);
|
||||
const content = typeof res === "string" ? res : "";
|
||||
const body = content.replace(/^\n+/, "").replace(/\s+$/, "");
|
||||
const block: string[] = [];
|
||||
if (!chapterHasOwnHeading(content, ch.title)) {
|
||||
block.push(`# ${ch.title}`);
|
||||
if (body) {
|
||||
block.push("");
|
||||
block.push(body);
|
||||
}
|
||||
} else if (body) {
|
||||
block.push(body);
|
||||
}
|
||||
if (block.length) parts.push(block.join("\n"));
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
async function renderFullBook() {
|
||||
const text = await assembleFullBook();
|
||||
setEditorText(fullBookEditor, text);
|
||||
fullBookStats.textContent = `words: ${countWords(text)} · chars: ${text.length}`;
|
||||
}
|
||||
|
||||
async function selectFullBook() {
|
||||
if (selectedId && dirty) {
|
||||
const res = await api.saveChapter(selectedId, editorText(editorView));
|
||||
if (!(res && "error" in res)) loadedContent = editorText(editorView);
|
||||
dirty = false;
|
||||
saveStateEl.textContent = "Saved";
|
||||
saveStateEl.classList.remove("unsaved");
|
||||
}
|
||||
clearTimers();
|
||||
selectedId = null;
|
||||
chapterList.querySelectorAll(".chapter-row").forEach((r) => r.classList.remove("active"));
|
||||
fullBookBtn.classList.add("active");
|
||||
chapterView.classList.add("hidden");
|
||||
editorPlaceholder.classList.add("hidden");
|
||||
fullBookView.classList.remove("hidden");
|
||||
await renderFullBook();
|
||||
fullBookEditor.focus();
|
||||
}
|
||||
|
||||
async function refreshFullBook() {
|
||||
if (fullBookView.classList.contains("hidden")) return;
|
||||
await renderFullBook();
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
|
||||
}
|
||||
|
|
@ -716,6 +805,25 @@ export function initBook(api: FolioAPI) {
|
|||
addChapterBtn.addEventListener("click", () => void create());
|
||||
exportBtn.addEventListener("click", () => void exportMarkdown());
|
||||
exportZipBtn.addEventListener("click", () => void exportZip());
|
||||
fullBookBtn.addEventListener("click", () => void selectFullBook());
|
||||
copyAllBtn.addEventListener("click", async () => {
|
||||
const text = editorText(fullBookEditor);
|
||||
if (!text) {
|
||||
fullBookStatus.textContent = "Nothing to copy";
|
||||
return;
|
||||
}
|
||||
const res = await api.copyText(text);
|
||||
if (res && "error" in res) {
|
||||
await alertMessage("Could not copy", (res as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
fullBookStatus.textContent = "Full book copied";
|
||||
fullBookStatus.classList.add("unsaved");
|
||||
setTimeout(() => {
|
||||
fullBookStatus.textContent = "";
|
||||
fullBookStatus.classList.remove("unsaved");
|
||||
}, 3000);
|
||||
});
|
||||
$("revealBtn").addEventListener("click", () => {
|
||||
if (currentBook) void api.revealInFolder(currentBook.path);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -117,6 +117,40 @@ html, body {
|
|||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ---- full book nav ---- */
|
||||
.full-book-nav {
|
||||
margin-top: var(--gap);
|
||||
padding-top: var(--gap);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.full-book-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.95rem;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.full-book-btn:hover {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.full-book-btn.active {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@
|
|||
<button class="icon-button" id="revealBtn" title="Reveal in folder">📁</button>
|
||||
</div>
|
||||
<ul class="chapter-list" id="chapterList"></ul>
|
||||
<div class="full-book-nav hidden" id="fullBookNav">
|
||||
<button class="full-book-btn" id="fullBookBtn" title="View the entire book as one continuous manuscript">Full Book</button>
|
||||
</div>
|
||||
<div class="sidebar-footer">
|
||||
<button class="button button-ghost" id="newChapterBtn">+ New Chapter</button>
|
||||
</div>
|
||||
|
|
@ -161,6 +164,17 @@
|
|||
</div>
|
||||
<div class="editor-panes" id="editorHost"></div>
|
||||
</div>
|
||||
<div class="full-book-view hidden" id="fullBookView">
|
||||
<header class="chapter-header">
|
||||
<h2 class="chapter-title">Full Book</h2>
|
||||
<div class="editor-actions">
|
||||
<span class="word-count" id="fullBookStats">words: 0 · chars: 0</span>
|
||||
<button class="button button-primary" id="copyAllBtn" title="Copy the entire assembled manuscript as Markdown">Copy All</button>
|
||||
<span class="save-state" id="fullBookStatus"></span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="editor-panes" id="fullBookHost"></div>
|
||||
</div>
|
||||
<div class="editor-placeholder hidden" id="editorPlaceholder">
|
||||
<p>Select a chapter to begin writing.</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { toggleBold, toggleItalic, toggleLink, toggleStrikethrough, toggleUnderl
|
|||
export interface MdEditorCallbacks {
|
||||
onChange: (text: string) => void;
|
||||
onSelection?: (view: EditorView) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const formatBindings: KeyBinding[] = [
|
||||
|
|
@ -101,6 +102,7 @@ const folioTheme = EditorView.theme({
|
|||
});
|
||||
|
||||
export function createMdEditor(host: HTMLElement, callbacks: MdEditorCallbacks): EditorView {
|
||||
const readOnly = !!callbacks.readOnly;
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "",
|
||||
|
|
@ -117,7 +119,12 @@ export function createMdEditor(host: HTMLElement, callbacks: MdEditorCallbacks):
|
|||
search({ top: true }),
|
||||
syntaxHighlighting(mdHighlight),
|
||||
folioTheme,
|
||||
keymap.of([...formatBindings, ...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab]),
|
||||
...(readOnly ? [EditorState.readOnly.of(true)] : []),
|
||||
keymap.of(
|
||||
readOnly
|
||||
? [...defaultKeymap, ...searchKeymap]
|
||||
: [...formatBindings, ...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab]
|
||||
),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) callbacks.onChange(update.state.doc.toString());
|
||||
if (callbacks.onSelection && (update.docChanged || update.selectionSet)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue