Stage 5: live Markdown editor (source+preview), autosave, word count, find bar, save-state indicator

This commit is contained in:
avi 2026-08-16 14:51:06 -05:00
commit 25baae2950
9 changed files with 390 additions and 6 deletions

98
package-lock.json generated
View file

@ -8,6 +8,9 @@
"name": "folio", "name": "folio",
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": {
"markdown-it": "^15.0.0"
},
"devDependencies": { "devDependencies": {
"@types/node": "^22.5.0", "@types/node": "^22.5.0",
"concurrently": "^9.0.0", "concurrently": "^9.0.0",
@ -651,6 +654,22 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "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": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -1023,6 +1042,18 @@
"once": "^1.4.0" "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": { "node_modules/env-paths": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@ -1576,6 +1607,25 @@
"json-buffer": "3.0.1" "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": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@ -1593,6 +1643,33 @@
"node": ">=8" "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": { "node_modules/matcher": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
@ -1617,6 +1694,12 @@
"node": ">= 0.4" "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": { "node_modules/mime-db": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@ -1749,6 +1832,15 @@
"once": "^1.3.1" "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": { "node_modules/quick-lru": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
@ -1979,6 +2071,12 @@
"node": ">=14.17" "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": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",

View file

@ -24,5 +24,8 @@
"esbuild": "^0.24.0", "esbuild": "^0.24.0",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"wait-on": "^8.0.0" "wait-on": "^8.0.0"
},
"dependencies": {
"markdown-it": "^15.0.0"
} }
} }

View file

@ -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 { export function createChapter(bookPath: string, title: string): ChapterEntry {
if (!title.trim()) throw new Error("Chapter title cannot be empty"); if (!title.trim()) throw new Error("Chapter title cannot be empty");
const meta = loadBook(bookPath); const meta = loadBook(bookPath);

View file

@ -20,6 +20,7 @@ import {
renameChapter, renameChapter,
reorderChapters, reorderChapters,
listChaptersInOrder, listChaptersInOrder,
setChapterContent,
} from "./chapters.js"; } from "./chapters.js";
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
@ -249,3 +250,12 @@ ipcMain.handle("folio:getChapterList", () => {
return { error: (e as Error).message }; 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 };
}
});

View file

@ -23,6 +23,8 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.invoke("folio:duplicateChapter", id, title), ipcRenderer.invoke("folio:duplicateChapter", id, title),
reorderChapters: (newOrder: string[]) => reorderChapters: (newOrder: string[]) =>
ipcRenderer.invoke("folio:reorderChapters", newOrder), ipcRenderer.invoke("folio:reorderChapters", newOrder),
saveChapter: (id: string, content: string) =>
ipcRenderer.invoke("folio:saveChapter", id, content),
// events // events
onBookOpened: (cb: (data: { path: string; title: string }) => void) => onBookOpened: (cb: (data: { path: string; title: string }) => void) =>

View file

@ -1,4 +1,5 @@
import type { BookMeta } from "../main/project.js"; import type { BookMeta } from "../main/project.js";
import markdownIt from "markdown-it";
export type FolioAPI = { export type FolioAPI = {
newBook: () => Promise<any>; newBook: () => Promise<any>;
@ -15,6 +16,7 @@ export type FolioAPI = {
deleteChapter: (id: string) => Promise<any>; deleteChapter: (id: string) => 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>;
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void; onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
onBookClosed: (cb: () => void) => void; onBookClosed: (cb: () => void) => void;
onChaptersChanged: (cb: () => void) => void; onChaptersChanged: (cb: () => void) => void;
@ -56,9 +58,23 @@ export function initBook(api: FolioAPI) {
const editorPlaceholder = $("editorPlaceholder"); const editorPlaceholder = $("editorPlaceholder");
const chapterView = $("chapterView"); const chapterView = $("chapterView");
const chapterTitleEl = $("chapterTitle"); const chapterTitleEl = $("chapterTitle");
const chapterContentEl = $("chapterContent"); const sourceArea = $("sourceArea") as HTMLTextAreaElement;
const editorPreview = $("editorPreview");
const wordCountEl = $("wordCount"); 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 projectBar = $("projectBar");
const md = markdownIt({ html: false, linkify: true, typographer: true });
let dirty = false;
let loadedContent = "";
let renderTimer: ReturnType<typeof setTimeout> | null = null;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const bookTitleEl = $("bookTitle"); const bookTitleEl = $("bookTitle");
const bookPathEl = $("bookPath"); const bookPathEl = $("bookPath");
const welcome = $("welcome"); const welcome = $("welcome");
@ -159,6 +175,14 @@ export function initBook(api: FolioAPI) {
} }
async function selectChapter(id: string) { 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; selectedId = id;
renderChapterList(); renderChapterList();
try { try {
@ -166,8 +190,16 @@ export function initBook(api: FolioAPI) {
const text = typeof content === "string" ? content : ""; const text = typeof content === "string" ? content : "";
const title = (meta && meta.chapters[id]?.title) || id; const title = (meta && meta.chapters[id]?.title) || id;
chapterTitleEl.textContent = title; 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)}`; wordCountEl.textContent = `words: ${countWords(text)}`;
renderPreview(text);
sourceArea.focus();
sourceArea.setSelectionRange(text.length, text.length);
clearTimers();
chapterView.classList.remove("hidden"); chapterView.classList.remove("hidden");
editorPlaceholder.classList.add("hidden"); editorPlaceholder.classList.add("hidden");
} catch { } 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(); const trimmed = text.trim();
if (!trimmed) return 0; if (!trimmed) return 0;
return trimmed.split(/\s+/).length; return trimmed.split(/\s+/).length;
@ -375,6 +480,35 @@ export function initBook(api: FolioAPI) {
if (currentBook) void api.revealInFolder(currentBook.path); 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.onBookOpened((data) => setCurrentBook(data));
api.onBookClosed(showWelcome); api.onBookClosed(showWelcome);
api.onChaptersChanged(() => void refreshMeta()); api.onChaptersChanged(() => void refreshMeta());

View file

@ -415,3 +415,112 @@ html, body {
outline: none; outline: none;
max-width: 720px; 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);
}

View file

@ -40,9 +40,22 @@
<div class="chapter-view hidden" id="chapterView"> <div class="chapter-view hidden" id="chapterView">
<header class="chapter-header"> <header class="chapter-header">
<h2 class="chapter-title" id="chapterTitle"></h2> <h2 class="chapter-title" id="chapterTitle"></h2>
<span class="word-count" id="wordCount"></span> <div class="editor-actions">
<span class="save-state" id="saveState">Saved</span>
<span class="word-count" id="wordCount">words: 0</span>
<button class="icon-button" id="findBtn" title="Find (Ctrl+F)">⌕</button>
</div>
</header> </header>
<pre class="chapter-content" id="chapterContent"></pre> <div class="editor-panes">
<textarea class="source-area" id="sourceArea" spellcheck="true" autocapitalize="sentences"></textarea>
<div class="editor-preview" id="editorPreview"></div>
</div>
<div class="find-bar hidden" id="findBar">
<input type="text" id="findIn" placeholder="Find…" />
<button class="icon-btn" id="findPrev" title="Previous (↑)">↑</button>
<button class="icon-btn" id="findNext" title="Next (↓)">↓</button>
<button class="icon-btn" id="findClose" title="Close (Esc)">×</button>
</div>
</div> </div>
<div class="editor-placeholder hidden" id="editorPlaceholder"> <div class="editor-placeholder hidden" id="editorPlaceholder">
<p>Select a chapter to begin writing.</p> <p>Select a chapter to begin writing.</p>

View file

@ -38,6 +38,7 @@ const {
deleteChapter, deleteChapter,
duplicateChapter, duplicateChapter,
reorderChapters, reorderChapters,
setChapterContent,
moveInOrder, moveInOrder,
} = chapters; } = chapters;
@ -83,6 +84,13 @@ ok(dup.id !== c1.id, "duplicate has new id");
ok(getChapterContent(dir, dup.id) === "Hello world this is text", "duplicate copies content"); ok(getChapterContent(dir, dup.id) === "Hello world this is text", "duplicate copies content");
ok(loadBook(dir).chapterOrder.slice(-1)[0] === dup.id, "duplicate appended to order"); ok(loadBook(dir).chapterOrder.slice(-1)[0] === dup.id, "duplicate appended to order");
// setChapterContent writes markdown to disk by chapter id
setChapterContent(dir, c1.id, "# Introduction\n\nWelcome.");
ok(getChapterContent(dir, c1.id) === "# Introduction\n\nWelcome.", "setChapterContent persists markdown");
let threw2 = false;
try { setChapterContent(dir, "nope", "x"); } catch { threw2 = true; }
ok(threw2, "setChapterContent throws on unknown id");
// delete // delete
const c3File = path.join(dir, c3.file); const c3File = path.join(dir, c3.file);
deleteChapter(dir, c3.id); deleteChapter(dir, c3.id);