diff --git a/FolioAppIcon.png b/FolioAppIcon.png new file mode 100644 index 0000000..bf0f971 Binary files /dev/null and b/FolioAppIcon.png differ diff --git a/src/main/index.ts b/src/main/index.ts index 72b02fb..0f23bcd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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({ diff --git a/src/main/menu.ts b/src/main/menu.ts index 66fd612..61f5d95 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -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"), + }, ], }; diff --git a/src/main/preload.ts b/src/main/preload.ts index 9952776..de16979 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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()), }); diff --git a/src/renderer/book.ts b/src/renderer/book.ts index d6639c9..eb60941 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -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; exportZip: () => Promise; importAttachment: () => Promise; + copyText: (text: string) => Promise; + getClipboardText: () => Promise; 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 { + 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); }); diff --git a/src/renderer/index.css b/src/renderer/index.css index c8b2d94..3b0aa06 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -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; diff --git a/src/renderer/index.html b/src/renderer/index.html index 37b2638..a655101 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -19,6 +19,9 @@
    + @@ -161,6 +164,17 @@
    + diff --git a/src/renderer/md-editor.ts b/src/renderer/md-editor.ts index da6b6b6..40b61cb 100644 --- a/src/renderer/md-editor.ts +++ b/src/renderer/md-editor.ts @@ -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)) {