From 25baae2950dbdc6d4d6f2c277e00fe0bc1a142a7 Mon Sep 17 00:00:00 2001 From: avi Date: Sun, 16 Aug 2026 14:51:06 -0500 Subject: [PATCH] Stage 5: live Markdown editor (source+preview), autosave, word count, find bar, save-state indicator --- package-lock.json | 98 +++++++++++++++++++++++++ package.json | 5 +- src/main/chapters.ts | 7 ++ src/main/index.ts | 10 +++ src/main/preload.ts | 2 + src/renderer/book.ts | 140 +++++++++++++++++++++++++++++++++++- src/renderer/index.css | 109 ++++++++++++++++++++++++++++ src/renderer/index.html | 17 ++++- tests/run-chapters-test.mjs | 8 +++ 9 files changed, 390 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2bd146b..c3c451b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "folio", "version": "0.1.0", "license": "MIT", + "dependencies": { + "markdown-it": "^15.0.0" + }, "devDependencies": { "@types/node": "^22.5.0", "concurrently": "^9.0.0", @@ -651,6 +654,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.0.tgz", + "integrity": "sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "Python-2.0" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1023,6 +1042,18 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -1576,6 +1607,25 @@ "json-buffer": "3.0.1" } }, + "node_modules/linkify-it": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz", + "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^3.0.0" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -1593,6 +1643,33 @@ "node": ">=8" } }, + "node_modules/markdown-it": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.0.tgz", + "integrity": "sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^3.0.0", + "entities": "^8.0.0", + "linkify-it": "^6.0.0", + "mdurl": "^2.1.0", + "punycode.js": "^2.3.1", + "uc.micro": "^3.0.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -1617,6 +1694,12 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -1749,6 +1832,15 @@ "once": "^1.3.1" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -1979,6 +2071,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz", + "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 6faf269..141afd7 100644 --- a/package.json +++ b/package.json @@ -24,5 +24,8 @@ "esbuild": "^0.24.0", "typescript": "^5.5.0", "wait-on": "^8.0.0" + }, + "dependencies": { + "markdown-it": "^15.0.0" } -} \ No newline at end of file +} diff --git a/src/main/chapters.ts b/src/main/chapters.ts index dae6226..119be20 100644 --- a/src/main/chapters.ts +++ b/src/main/chapters.ts @@ -27,6 +27,13 @@ export function getChapterContent(bookPath: string, id: string): string { } } +export function setChapterContent(bookPath: string, id: string, content: string): void { + const meta = loadBook(bookPath); + const entry = meta.chapters[id]; + if (!entry) throw new Error("Chapter not found"); + fs.writeFileSync(path.join(bookPath, entry.file), content, "utf-8"); +} + export function createChapter(bookPath: string, title: string): ChapterEntry { if (!title.trim()) throw new Error("Chapter title cannot be empty"); const meta = loadBook(bookPath); diff --git a/src/main/index.ts b/src/main/index.ts index 7103689..83eb3e6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -20,6 +20,7 @@ import { renameChapter, reorderChapters, listChaptersInOrder, + setChapterContent, } from "./chapters.js"; let mainWindow: BrowserWindow | null = null; @@ -249,3 +250,12 @@ ipcMain.handle("folio:getChapterList", () => { return { error: (e as Error).message }; } }); + +ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => { + try { + setChapterContent(requireBook(), id, content); + return { ok: true }; + } catch (e) { + return { error: (e as Error).message }; + } +}); diff --git a/src/main/preload.ts b/src/main/preload.ts index d4b3ece..ad43bc5 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -23,6 +23,8 @@ contextBridge.exposeInMainWorld("folio", { ipcRenderer.invoke("folio:duplicateChapter", id, title), reorderChapters: (newOrder: string[]) => ipcRenderer.invoke("folio:reorderChapters", newOrder), + saveChapter: (id: string, content: string) => + ipcRenderer.invoke("folio:saveChapter", id, content), // events onBookOpened: (cb: (data: { path: string; title: string }) => void) => diff --git a/src/renderer/book.ts b/src/renderer/book.ts index 15e2d0b..c6dc2cc 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -1,4 +1,5 @@ import type { BookMeta } from "../main/project.js"; +import markdownIt from "markdown-it"; export type FolioAPI = { newBook: () => Promise; @@ -15,6 +16,7 @@ export type FolioAPI = { deleteChapter: (id: string) => Promise; duplicateChapter: (id: string, title?: string) => Promise; reorderChapters: (newOrder: string[]) => Promise; + saveChapter: (id: string, content: string) => Promise; onBookOpened: (cb: (data: { path: string; title: string }) => void) => void; onBookClosed: (cb: () => void) => void; onChaptersChanged: (cb: () => void) => void; @@ -56,9 +58,23 @@ export function initBook(api: FolioAPI) { const editorPlaceholder = $("editorPlaceholder"); const chapterView = $("chapterView"); const chapterTitleEl = $("chapterTitle"); - const chapterContentEl = $("chapterContent"); + const sourceArea = $("sourceArea") as HTMLTextAreaElement; + const editorPreview = $("editorPreview"); const wordCountEl = $("wordCount"); + const saveStateEl = $("saveState"); + const findBtn = $("findBtn") as HTMLButtonElement; + const findBar = $("findBar"); + const findIn = $("findIn") as HTMLInputElement; + const findPrevBtn = $("findPrev") as HTMLButtonElement; + const findNextBtn = $("findNext") as HTMLButtonElement; + const findCloseBtn = $("findClose") as HTMLButtonElement; const projectBar = $("projectBar"); + + const md = markdownIt({ html: false, linkify: true, typographer: true }); + let dirty = false; + let loadedContent = ""; + let renderTimer: ReturnType | null = null; + let saveTimer: ReturnType | null = null; const bookTitleEl = $("bookTitle"); const bookPathEl = $("bookPath"); const welcome = $("welcome"); @@ -159,6 +175,14 @@ export function initBook(api: FolioAPI) { } async function selectChapter(id: string) { + if (selectedId && dirty) { + const res = await api.saveChapter(selectedId, sourceArea.value); + if (!(res && "error" in res)) loadedContent = sourceArea.value; + dirty = false; + saveStateEl.textContent = "Saved"; + saveStateEl.classList.remove("unsaved"); + } + clearTimers(); selectedId = id; renderChapterList(); try { @@ -166,8 +190,16 @@ export function initBook(api: FolioAPI) { const text = typeof content === "string" ? content : ""; const title = (meta && meta.chapters[id]?.title) || id; chapterTitleEl.textContent = title; - chapterContentEl.textContent = text; + sourceArea.value = text; + loadedContent = text; + dirty = false; + saveStateEl.textContent = "Saved"; + saveStateEl.classList.remove("unsaved"); wordCountEl.textContent = `words: ${countWords(text)}`; + renderPreview(text); + sourceArea.focus(); + sourceArea.setSelectionRange(text.length, text.length); + clearTimers(); chapterView.classList.remove("hidden"); editorPlaceholder.classList.add("hidden"); } catch { @@ -175,7 +207,80 @@ export function initBook(api: FolioAPI) { } } - function countWords(text: string): number { + function clearTimers() { + if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; } + if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } + } + + function renderPreview(text: string) { + editorPreview.innerHTML = md.render(text); + // smooth-scroll anchor links open in same preview pane + editorPreview.querySelectorAll("a").forEach((a) => + a.setAttribute("target", "_blank") + ); + } + + async function scheduleSave() { + if (!selectedId) return; + const content = sourceArea.value; + dirty = content !== loadedContent; + saveStateEl.textContent = dirty ? "Unsaved" : "Saved"; + saveStateEl.classList.toggle("unsaved", dirty); + wordCountEl.textContent = `words: ${countWords(content)}`; + clearTimers(); + renderTimer = setTimeout(() => renderPreview(content), 150); + if (dirty) { + saveTimer = setTimeout(async () => { + const res = await api.saveChapter(selectedId!, content); + if (!(res && "error" in res)) { + loadedContent = content; + dirty = false; + saveStateEl.textContent = "Saved"; + saveStateEl.classList.remove("unsaved"); + } + }, 600); + } + } + + // ---- find in editor ---- + function findNext(fromInput = false): boolean { + const needle = findIn.value; + if (!needle) return false; + const ta = sourceArea; + const cur = ta.selectionStart; + const idx = ta.value.toLowerCase().indexOf(needle.toLowerCase(), fromInput ? 0 : cur + 1); + if (idx === -1) return false; + ta.focus(); + ta.setSelectionRange(idx, idx + needle.length); + return true; + } + + function findPrev(): boolean { + const needle = findIn.value; + if (!needle) return false; + const ta = sourceArea; + const cur = ta.selectionStart; + const slice = ta.value.slice(0, cur).toLowerCase(); + const idx = slice.lastIndexOf(needle.toLowerCase()); + if (idx === -1) return false; + ta.focus(); + ta.setSelectionRange(idx, idx + needle.length); + return true; + } + + function openFindBar() { + findBar.classList.remove("hidden"); + findIn.value = ""; + findIn.focus(); + sourceArea.dispatchEvent(new Event("blur")); + } + + function closeFindBar() { + findBar.classList.add("hidden"); + sourceArea.focus(); + } + + function countWords(text: string) { const trimmed = text.trim(); if (!trimmed) return 0; return trimmed.split(/\s+/).length; @@ -375,6 +480,35 @@ export function initBook(api: FolioAPI) { if (currentBook) void api.revealInFolder(currentBook.path); }); + // ---- editor wiring ---- + sourceArea.addEventListener("input", () => void scheduleSave()); + + findBtn.addEventListener("click", () => openFindBar()); + findNextBtn.addEventListener("click", () => { if (!findNext(false)) findIn.value = ""; }); + findPrevBtn.addEventListener("click", () => findPrev()); + findCloseBtn.addEventListener("click", () => closeFindBar()); + findIn.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) findPrev(); else findNext(false); + } else if (e.key === "Escape") { + closeFindBar(); + } + }); + + // Ctrl/Cmd+F to open find; Esc to close when open; keep editor focused otherwise + document.addEventListener("keydown", (e) => { + if (e.key === "f" && (e.ctrlKey || e.metaKey) && !findBar.classList.contains("hidden")) { + e.preventDefault(); + } + if (e.key === "F" && (e.ctrlKey || e.metaKey) && findBar.classList.contains("hidden") && selectedId) { + e.preventDefault(); + openFindBar(); + } else if (e.key === "Escape" && !findBar.classList.contains("hidden")) { + closeFindBar(); + } + }); + api.onBookOpened((data) => setCurrentBook(data)); api.onBookClosed(showWelcome); api.onChaptersChanged(() => void refreshMeta()); diff --git a/src/renderer/index.css b/src/renderer/index.css index 57c0270..4f86d7d 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -415,3 +415,112 @@ html, body { outline: none; max-width: 720px; } + +/* ---- editor ---- */ +.chapter-view { + flex: 1; + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; +} + +.chapter-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 14px 20px; + border-bottom: 1px solid var(--color-border); +} + +.editor-actions { + display: flex; + align-items: center; + gap: 10px; + color: var(--color-muted); + font-size: 0.72rem; +} + +.save-state.unsaved { + color: var(--color-accent); + font-weight: 600; +} + +.editor-panes { + display: flex; + flex: 1; + overflow: hidden; + gap: 0; +} + +.source-area { + flex: 1; + min-width: 0; + border: 0; + resize: none; + outline: none; + padding: 24px 20px; + font-family: var(--font-serif); + font-size: 1.05rem; + line-height: 1.7; + color: var(--color-text); + background: var(--color-bg); + tab-size: 4; +} + +.source-area:focus { + background: var(--color-panel); +} + +.editor-preview { + flex: 1; + min-width: 0; + overflow-y: auto; + padding: 24px 20px; + font-family: var(--font-serif); + font-size: 1.05rem; + line-height: 1.7; + color: var(--color-text); + background: var(--color-bg); + border-left: 1px solid var(--color-border); +} + +.editor-preview img { + max-width: 100%; + height: auto; +} + +.editor-preview pre { + background: var(--color-panel); + padding: 12px; + border-radius: var(--radius); + overflow-x: auto; +} + +.editor-preview code { + font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; + background: var(--color-panel); + padding: 2px 6px; + border-radius: 4px; +} + +.find-bar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + background: var(--color-panel); + border-bottom: 1px solid var(--color-border); +} + +.find-bar input { + flex: 1; + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 4px 8px; + font-size: 0.85rem; + background: var(--color-bg); + color: var(--color-text); +} + diff --git a/src/renderer/index.html b/src/renderer/index.html index 05d9db8..80e5f61 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -40,9 +40,22 @@